diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 4f5d1e6c2..84dd75bbf 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -43,6 +43,7 @@ import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/pangea/login/pages/space_code_onboarding.dart'; import 'package:fluffychat/pangea/login/pages/user_settings.dart'; import 'package:fluffychat/pangea/onboarding/onboarding.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; import 'package:fluffychat/pangea/spaces/utils/join_with_alias.dart'; import 'package:fluffychat/pangea/spaces/utils/join_with_link.dart'; @@ -619,6 +620,17 @@ abstract class AppRoutes { ), routes: [ // #Pangea + GoRoute( + path: '/analytics', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + SpaceAnalytics( + roomId: state.pathParameters['roomid']!, + ), + ), + ), GoRoute( path: 'planner', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 209a22b37..2ad7411e1 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -4199,7 +4199,7 @@ "roomAddedToSpace": "Room(s) have been added to the selected space.", "addChatToSpaceDesc": "Adding a chat to a space will make the chat appear within the space for students and give them access.", "addSpaceToSpaceDesc": "Adding a sub space to space will make the sub space appear in the main space's chat list.", - "spaceAnalytics": "Space Analytics", + "spaceAnalytics": "Space analytics", "changeAnalyticsLanguage": "Change Analytics Language", "suggestToSpace": "Suggest this space", "suggestToSpaceDesc": "Suggested sub spaces will appear in their main space's chat list", @@ -5093,6 +5093,22 @@ "errorFetchingActivity": "Failed to fetch activity", "check": "Check", "unableToFindRoom": "No chat or space found with that code. Please try again.", + "spaceAnalyticsPage": "Space analytics page", + "numCompletedActivities": "Number of completed activities", + "viewingAnalytics": "Viewing {visible}/{users} Analytics", + "@viewingAnalytics": { + "type": "String", + "placeholders": { + "visible": { + "type": "int" + }, + "users": { + "type": "int" + } + } + }, + "request": "Request", + "requestAll": "Request All", "confirmMessageUnpin": "Are you sure you want to unpin this message?", "createActivityPlan": "Create a new activity plan", "saveAndLaunch": "Save and Launch", @@ -5107,6 +5123,8 @@ } } }, + "pending": "Pending", + "inactive": "Inactive", "unjoinedActivityMessage": "Do you want to participate? Choose an open role!\nOr hang out and watch the show!", "fullActivityMessage": "Feel free to watch the show! While there aren't any open roles to participate, you can view the chat!", "confirmRole": "Confirm role", @@ -5137,5 +5155,41 @@ "archiveToAnalytics": "Add to my Completed Activities", "activitySummaryError": "Activity summaries unavailable", "requestSummaries": "Request summaries", - "loadingActivitySummary": "Loading activity summary..." + "loadingActivitySummary": "Loading activity summary...", + "requestAccessTitle": "Request to analytics view access?", + "requestAccessDesc": "Would you like to request access to view participant analytics?\n\nIf participants agree, admin of this space will be able to view their:\n • total vocabulary\n • total grammar concepts\n • total activity sessions completed\n • the specific grammar concepts used, correctly and incorrectly\n\n • They will not be able to view their:\n • messages in chats outside the space\n • vocabulary list", + "requestAccess": "Request access ({count})", + "@requestAccess": { + "type": "String", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "analyticsInactiveTitle": "Requests to inactive users couldn’t be sent", + "analyticsInactiveDesc": "Inactive users who haven't logged in since this feature was introduced won't see your request.\n\nThe Request button will appear once they return. You can resend the request later by clicking the Request button under their name when it's available.", + "accessRequestedTitle": "Analytics Access Request", + "accessRequestedDesc": "The administrators of “{space}” are requesting to view your learning analytics.\n\nIf you agree, admin of this space will be able to view your:\n • total vocabulary\n • total grammar concepts\n • total activity sessions completed\n • the specific grammar concepts used, correctly and incorrectly\n\n • They will not be able to view your:\n • messages in chats outside the space\n • vocabulary list", + "@accessRequestedDesc": { + "type": "String", + "placeholders": { + "space": { + "type": "String" + } + } + }, + "allowAccess": "Allow Access", + "denyAccess": "Deny Access", + "adminRequestedAccess": "Admins asked to view your analytics.", + "lastUpdated": "Updated\n{time}", + "@lastUpdated": { + "type": "String", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "noDataFound": "No data found" } diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 1c7975dac..2aa967bcc 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; +import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart'; import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart'; @@ -804,6 +805,7 @@ class _SpaceViewState extends State { ); }, ), + AnalyticsRequestIndicator(room: room), // Pangea# SliverList.builder( itemCount: joinedRooms.length, diff --git a/lib/pangea/analytics_downloads/space_analytics_summary_enum.dart b/lib/pangea/analytics_downloads/space_analytics_summary_enum.dart index cdb5ee98e..09f2c7a2a 100644 --- a/lib/pangea/analytics_downloads/space_analytics_summary_enum.dart +++ b/lib/pangea/analytics_downloads/space_analytics_summary_enum.dart @@ -42,6 +42,9 @@ enum SpaceAnalyticsSummaryEnum { // list morph > 500 XP listMorphHugeXP, + + /// Number of completed activities + numCompletedActivities, } extension AnalyticsSummaryEnumExtension on SpaceAnalyticsSummaryEnum { @@ -95,6 +98,8 @@ extension AnalyticsSummaryEnumExtension on SpaceAnalyticsSummaryEnum { return l10n.numCorrectChoices; case SpaceAnalyticsSummaryEnum.numChoicesIncorrect: return l10n.numIncorrectChoices; + case SpaceAnalyticsSummaryEnum.numCompletedActivities: + return l10n.numCompletedActivities; } } } diff --git a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart index a06c0c5ee..bb5b0c05a 100644 --- a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart +++ b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart @@ -50,6 +50,8 @@ class SpaceAnalyticsSummaryModel { int? numChoicesCorrect; int? numChoicesIncorrect; + int numCompletedActivities; + SpaceAnalyticsSummaryModel({ required this.username, required this.dataAvailable, @@ -75,6 +77,7 @@ class SpaceAnalyticsSummaryModel { this.numWordsTyped, this.numChoicesCorrect, this.numChoicesIncorrect, + this.numCompletedActivities = 0, }); static SpaceAnalyticsSummaryModel emptyModel(String userID) { @@ -87,6 +90,7 @@ class SpaceAnalyticsSummaryModel { static SpaceAnalyticsSummaryModel fromConstructListModel( String userID, ConstructListModel? model, + int numCompletedActivities, String Function(ConstructUses) getCopy, BuildContext context, ) { @@ -207,6 +211,7 @@ class SpaceAnalyticsSummaryModel { numWordsTyped: numWordsTyped, numChoicesCorrect: numChoicesCorrect, numChoicesIncorrect: numChoicesIncorrect, + numCompletedActivities: numCompletedActivities, ); } @@ -262,6 +267,8 @@ class SpaceAnalyticsSummaryModel { return numChoicesCorrect; case SpaceAnalyticsSummaryEnum.numChoicesIncorrect: return numChoicesIncorrect; + case SpaceAnalyticsSummaryEnum.numCompletedActivities: + return numCompletedActivities; } } @@ -290,6 +297,7 @@ class SpaceAnalyticsSummaryModel { 'numWordsWithoutAssistance': numWordsTyped, 'numChoicesCorrect': numChoicesCorrect, 'numChoicesIncorrect': numChoicesIncorrect, + 'numCompletedActivities': numCompletedActivities, }; } } diff --git a/lib/pangea/analytics_misc/client_analytics_extension.dart b/lib/pangea/analytics_misc/client_analytics_extension.dart index 9eb1ca142..04e884d7b 100644 --- a/lib/pangea/analytics_misc/client_analytics_extension.dart +++ b/lib/pangea/analytics_misc/client_analytics_extension.dart @@ -87,44 +87,35 @@ extension AnalyticsClientExtension on Client { } /// Get all my analytics rooms - List get _allMyAnalyticsRooms => rooms + List get allMyAnalyticsRooms => rooms .where( (e) => e.isAnalyticsRoomOfUser(userID!), ) .toList(); - /// Update the visibility of all analytics rooms to private (do they don't show in search - /// results) and set the join rules to public (so they come through in space hierarchy response) - Future updateAnalyticsRoomVisibility() async { + /// Update the join rules of all analytics rooms to 'knock'. + Future updateAnalyticsRoomJoinRules() async { + if (prevBatch == null) await onSync.stream.first; if (userID == null || userID == BotName.byEnvironment) return; final Random random = Random(); - for (final analyticsRoom in _allMyAnalyticsRooms) { - if (userID == null) return; - final visibility = await getRoomVisibilityOnDirectory(analyticsRoom.id); + for (final analyticsRoom in allMyAnalyticsRooms) { + if (!isLogged()) return; + if (analyticsRoom.joinRules == JoinRules.knock) continue; - // if making a call to the server (either to update visibility or join rules) - // add a delay at the end of this interaction to prevent overloading the server - int delay = 0; - if (visibility != Visibility.private || - analyticsRoom.joinRules != JoinRules.public) { - delay = random.nextInt(10); - } + await analyticsRoom.setJoinRules(JoinRules.knock); + await Future.delayed(Duration(seconds: random.nextInt(10))); + } + } - // don't show in search results - if (visibility != Visibility.private) { - await setRoomVisibilityOnDirectory( - analyticsRoom.id, - visibility: Visibility.private, - ); - } + Future loadAnalyticsRequests() async { + if (prevBatch == null) await onSync.stream.first; + if (userID == null || userID == BotName.byEnvironment) return; - // do show in space hierarchy - if (analyticsRoom.joinRules != JoinRules.public) { - await analyticsRoom.setJoinRules(JoinRules.public); - } - - await Future.delayed(Duration(seconds: delay)); + for (final analyticsRoom in allMyAnalyticsRooms) { + if (!isLogged()) return; + analyticsRoom.requestParticipants([Membership.knock], false, true); + analyticsRoom.postLoad(); } } @@ -141,7 +132,7 @@ extension AnalyticsClientExtension on Client { final Random random = Random(); for (final space in spaces) { if (userID == null || !space.canSendEvent(EventTypes.SpaceChild)) return; - final List roomsNotAdded = _allMyAnalyticsRooms.where((room) { + final List roomsNotAdded = allMyAnalyticsRooms.where((room) { return !space.spaceChildren.any((child) => child.roomId == room.id); }).toList(); diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index 20f76f096..addc7e760 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -76,8 +76,9 @@ class GetAnalyticsController extends BaseController { try { await GetStorage.init("analytics_storage"); - _client.updateAnalyticsRoomVisibility(); + _client.updateAnalyticsRoomJoinRules(); _client.addAnalyticsRoomsToSpaces(); + _client.loadAnalyticsRequests(); _analyticsUpdateSubscription ??= _pangeaController .putAnalytics.analyticsUpdateStream.stream diff --git a/lib/pangea/analytics_misc/room_analytics_extension.dart b/lib/pangea/analytics_misc/room_analytics_extension.dart index 4d7117e57..033e43061 100644 --- a/lib/pangea/analytics_misc/room_analytics_extension.dart +++ b/lib/pangea/analytics_misc/room_analytics_extension.dart @@ -16,7 +16,7 @@ extension AnalyticsRoomExtension on Room { /// /// Yield this list of rooms. /// Once analytics have been retrieved, leave analytics rooms not created by self. - Stream> getNextAnalyticsRoomBatch(String userL2) async* { + Stream> getNextAnalyticsRoomBatch(String langCode) async* { final List rooms = []; String? nextBatch; int spaceHierarchyCalls = 0; @@ -50,8 +50,8 @@ extension AnalyticsRoomExtension on Room { ); final (analyticsRoom, calls) = matchingRoom != null - ? await _handleJoinedAnalyticsRoom(matchingRoom, userL2) - : await _handleUnjoinedAnalyticsRoom(nextRoomChunk, userL2); + ? await _handleJoinedAnalyticsRoom(matchingRoom, langCode) + : await _handleUnjoinedAnalyticsRoom(nextRoomChunk, langCode); callsToServer += calls; if (analyticsRoom == null) continue; diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index 89d453d08..ab2ec733e 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; @@ -12,6 +13,7 @@ import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicator_button.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicator.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -153,8 +155,12 @@ class LearningProgressIndicatorsState ), const SizedBox(width: 6.0), AnimatedFloatingNumber( - number: MatrixState.pangeaController - .getAnalytics.archivedActivities.length, + number: Matrix.of(context) + .client + .analyticsRoomLocal() + ?.activityRoomIds + .length ?? + 0, ), ], ), diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 0f9bf2a3a..578073c2a 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -22,7 +22,6 @@ import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; -import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/url_launcher.dart'; @@ -384,16 +383,12 @@ class RoomDetailsButtonRowState extends State { showInMainView: false, ), ButtonDetails( - title: l10n.downloadSpaceAnalytics, - icon: const Icon(Icons.download_outlined, size: 30.0), - onPressed: () { - showDialog( - context: context, - builder: (context) => DownloadAnalyticsDialog(space: room), - ); - }, - visible: room.isSpace && room.isRoomAdmin && kIsWeb, - showInMainView: false, + title: l10n.spaceAnalytics, + icon: const Icon(Icons.bar_chart, size: 30.0), + onPressed: () => context.go('/rooms/${room.id}/details/analytics'), + visible: room.isSpace, + enabled: room.isSpace && room.isRoomAdmin, + showInMainView: true, ), ButtonDetails( title: l10n.download, diff --git a/lib/pangea/chat_settings/widgets/download_space_analytics_button.dart b/lib/pangea/chat_settings/widgets/download_space_analytics_button.dart deleted file mode 100644 index a9bc1007d..000000000 --- a/lib/pangea/chat_settings/widgets/download_space_analytics_button.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart'; - -class DownloadSpaceAnalyticsButton extends StatelessWidget { - final Room space; - - const DownloadSpaceAnalyticsButton({ - super.key, - required this.space, - }); - - @override - Widget build(BuildContext context) { - if (!kIsWeb) { - return const SizedBox.shrink(); - } - final iconColor = Theme.of(context).textTheme.bodyLarge!.color; - return Column( - children: [ - ListTile( - onTap: () { - showDialog( - context: context, - builder: (context) => DownloadAnalyticsDialog(space: space), - ); - }, - leading: CircleAvatar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon(Icons.download_outlined), - ), - title: Text( - L10n.of(context).downloadSpaceAnalytics, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ); - } -} diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index e062ffa33..4edbc0213 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -154,6 +154,7 @@ class ModelKey { static const String analytics = "analytics"; static const String level = "level"; static const String xpOffset = "xp_offset"; + static const String analyticsRoomId = "analytics_room_id"; // activity plan static const String activityPlanRequest = "req"; diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 2699bcde4..58ac834e2 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -127,6 +127,7 @@ class PangeaController { 'subscription_storage', 'vocab_storage', 'onboarding_storage', + 'analytics_request_storage', ]; Future clearCache({List exclude = const []}) async { diff --git a/lib/pangea/common/widgets/full_width_dialog.dart b/lib/pangea/common/widgets/full_width_dialog.dart index c95060407..aeaa6d416 100644 --- a/lib/pangea/common/widgets/full_width_dialog.dart +++ b/lib/pangea/common/widgets/full_width_dialog.dart @@ -8,11 +8,13 @@ class FullWidthDialog extends StatelessWidget { final Widget dialogContent; final double maxWidth; final double maxHeight; + final Color? backgroundColor; const FullWidthDialog({ required this.dialogContent, required this.maxWidth, required this.maxHeight, + this.backgroundColor, super.key, }); @@ -42,8 +44,14 @@ class FullWidthDialog extends StatelessWidget { return BackdropFilter( filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), child: isColumnMode - ? Dialog(child: content) - : Dialog.fullscreen(child: content), + ? Dialog( + backgroundColor: backgroundColor, + child: content, + ) + : Dialog.fullscreen( + backgroundColor: backgroundColor, + child: content, + ), ); } } diff --git a/lib/pangea/space_analytics/analytics_download_model.dart b/lib/pangea/space_analytics/analytics_download_model.dart new file mode 100644 index 000000000..2cd8f2f77 --- /dev/null +++ b/lib/pangea/space_analytics/analytics_download_model.dart @@ -0,0 +1,14 @@ +import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart'; + +class AnalyticsDownload { + RequestStatus requestStatus; + DownloadStatus downloadStatus; + SpaceAnalyticsSummaryModel? summary; + + AnalyticsDownload({ + required this.requestStatus, + required this.downloadStatus, + this.summary, + }); +} diff --git a/lib/pangea/space_analytics/analytics_request_indicator.dart b/lib/pangea/space_analytics/analytics_request_indicator.dart new file mode 100644 index 000000000..81f73c5e2 --- /dev/null +++ b/lib/pangea/space_analytics/analytics_request_indicator.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_requested_dialog.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class AnalyticsRequestIndicator extends StatefulWidget { + final Room room; + const AnalyticsRequestIndicator({ + super.key, + required this.room, + }); + + @override + AnalyticsRequestIndicatorState createState() => + AnalyticsRequestIndicatorState(); +} + +class AnalyticsRequestIndicatorState extends State { + AnalyticsRequestIndicatorState(); + + Map> get _knockingAdmins { + final admins = + widget.room.getParticipants().where((u) => u.powerLevel >= 100); + + final knockingAdmins = >{}; + for (final analyticsRoom in widget.room.client.allMyAnalyticsRooms) { + final knocking = analyticsRoom.getParticipants([Membership.knock]); + if (knocking.isEmpty) continue; + + for (final admin in admins) { + if (knocking.any((u) => u.id == admin.id)) { + knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom); + } + } + } + + return knockingAdmins; + } + + Future _onTap(BuildContext context) async { + final resp = await showDialog( + context: context, + builder: (context) { + return SpaceAnalyticsRequestedDialog(room: widget.room); + }, + ); + + if (resp is! bool) return; + + await showFutureLoadingDialog( + context: context, + future: () async { + for (final entry in _knockingAdmins.entries) { + final user = entry.key; + final rooms = entry.value; + + final List futures = rooms + .map((room) => resp ? room.invite(user.id) : room.kick(user.id)) + .toList(); + + await Future.wait(futures); + } + }, + ); + + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + return SliverList.builder( + itemCount: 1, + itemBuilder: (context, i) { + return AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _knockingAdmins.isEmpty + ? const SizedBox() + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + child: Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + clipBehavior: Clip.hardEdge, + child: ListTile( + minVerticalPadding: 0, + trailing: Icon( + Icons.arrow_right, + size: 20, + color: Theme.of(context).colorScheme.error, + ), + title: Row( + spacing: 8.0, + children: [ + Icon( + Icons.notifications_active_outlined, + color: Theme.of(context).colorScheme.error, + ), + Expanded( + child: Text( + L10n.of(context).adminRequestedAccess, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + onTap: () => _onTap(context), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/space_analytics/analytics_requests_repo.dart b/lib/pangea/space_analytics/analytics_requests_repo.dart new file mode 100644 index 000000000..afc0ed385 --- /dev/null +++ b/lib/pangea/space_analytics/analytics_requests_repo.dart @@ -0,0 +1,97 @@ +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart'; + +class _AnalyticsRequestEntry { + final RequestStatus status; + final DateTime timestamp; + + _AnalyticsRequestEntry({ + required this.status, + required this.timestamp, + }); + + Map toJson() { + return { + 'status': status.name, + 'timestamp': timestamp.toIso8601String(), + }; + } + + _AnalyticsRequestEntry.fromJson(Map json) + : status = RequestStatus.fromString(json['status']) ?? + RequestStatus.unrequested, + timestamp = DateTime.parse(json['timestamp']); + + bool get isExpired { + final now = DateTime.now(); + const expirationDuration = Duration(days: 1); + return now.isAfter(timestamp.add(expirationDuration)); + } +} + +class AnalyticsRequestsRepo { + static final GetStorage _requestStorage = + GetStorage('analytics_request_storage'); + + static String _storageKey(String userId, LanguageModel language) { + return 'analytics_request_${userId}_${language.langCodeShort}'; + } + + static RequestStatus? get(String userId, LanguageModel language) { + final key = _storageKey(userId, language); + final entry = _requestStorage.read(key); + if (entry == null) { + return null; + } + + final status = _AnalyticsRequestEntry.fromJson(entry); + if (status.isExpired) { + _requestStorage.remove(key); + return null; + } + + return status.status; + } + + static List getAll() { + final entries = _requestStorage.getValues(); + final statuses = []; + + for (final entry in entries) { + if (entry is Map) { + final status = _AnalyticsRequestEntry.fromJson(entry); + if (!status.isExpired) { + statuses.add(status.status); + } else { + // Remove expired entry + _requestStorage.remove(entry['key']); + } + } + } + + return statuses.toSet().toList(); + } + + static Future set( + String userId, + LanguageModel language, + RequestStatus status, + ) async { + final key = _storageKey(userId, language); + final entry = _AnalyticsRequestEntry( + status: status, + timestamp: DateTime.now(), + ); + await _requestStorage.write(key, entry.toJson()); + } + + static Future remove( + String userId, + LanguageModel language, + ) async { + final key = _storageKey(userId, language); + await _requestStorage.remove(key); + } +} diff --git a/lib/pangea/space_analytics/space_analytics.dart b/lib/pangea/space_analytics/space_analytics.dart new file mode 100644 index 000000000..98cda733c --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics.dart @@ -0,0 +1,455 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:intl/intl.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/space_analytics/analytics_download_model.dart'; +import 'package:fluffychat/pangea/space_analytics/analytics_requests_repo.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_inactive_dialog.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_request_dialog.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_view.dart'; +import 'package:fluffychat/pangea/user/models/profile_model.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +// enum DownloadStatus { +// loading, +// available, +// unavailable, +// notFound; +// } + +// enum RequestStatus { +// available, +// unrequested, +// requested, +// notFound; + +// static RequestStatus? fromString(String value) { +// switch (value) { +// case 'available': +// return RequestStatus.available; +// case 'unrequested': +// return RequestStatus.unrequested; +// case 'requested': +// return RequestStatus.requested; +// case 'notFound': +// return RequestStatus.notFound; +// default: +// return null; +// } +// } + +// IconData get icon { +// switch (this) { +// case RequestStatus.available: +// return Icons.check_circle; +// case RequestStatus.unrequested: +// return Symbols.approval_delegation; +// case RequestStatus.requested: +// return Icons.mark_email_read_outlined; +// case RequestStatus.notFound: +// return Symbols.approval_delegation; +// } +// } + +// String label(BuildContext context) { +// final l10n = L10n.of(context); +// switch (this) { +// case RequestStatus.available: +// return l10n.available; +// case RequestStatus.unrequested: +// return l10n.request; +// case RequestStatus.requested: +// return l10n.pending; +// case RequestStatus.notFound: +// return l10n.inactive; +// } +// } + +// Color backgroundColor(BuildContext context) { +// final theme = Theme.of(context); +// switch (this) { +// case RequestStatus.available: +// case RequestStatus.unrequested: +// return theme.colorScheme.primaryContainer; +// case RequestStatus.notFound: +// case RequestStatus.requested: +// return theme.disabledColor; +// } +// } + +// bool get showButton => this != RequestStatus.available; + +// bool get enabled => this == RequestStatus.unrequested; +// } + +// class AnalyticsDownload { +// DownloadStatus status; +// SpaceAnalyticsSummaryModel? summary; + +// AnalyticsDownload({ +// required this.status, +// this.summary, +// }); +// } + +class SpaceAnalytics extends StatefulWidget { + final String roomId; + const SpaceAnalytics({super.key, required this.roomId}); + + @override + SpaceAnalyticsState createState() => SpaceAnalyticsState(); +} + +class SpaceAnalyticsState extends State { + bool initialized = false; + LanguageModel? selectedLanguage; + Map downloads = {}; + + DateTime? _lastUpdated; + final Map _profiles = {}; + final Map> _langsToUsers = {}; + + Room? get room => Matrix.of(context).client.getRoomById(widget.roomId); + + LanguageModel? get _userL2 { + final l2 = MatrixState.pangeaController.languageController.userL2; + if (l2 == null) return null; + + // Attempt to find the language model by its short code, since analytics + // aren't divided by full language model but by short code. + return PLanguageStore.byLangCode(l2.langCodeShort) ?? l2; + } + + List get _availableUsers => + room + ?.getParticipants() + .where( + (member) => + member.id != BotName.byEnvironment && + member.membership == Membership.join, + ) + .toList() ?? + []; + + List get _availableUsersForLang => + _langsToUsers[selectedLanguage] ?? []; + + List get availableAnalyticsRooms => _availableUsersForLang + .map((user) => _analyticsRoomOfUser(user)) + .whereType() + .toList(); + + List get availableLanguages => _langsToUsers.keys.toList() + ..sort((a, b) => a.langCode.compareTo(b.langCode)); + + int get completedDownloads => + downloads.values.where((d) => d.summary != null).length; + + List> get sortedDownloads { + final entries = downloads.entries.toList(); + entries.sort((a, b) { + final aStatus = a.value.requestStatus; + final bStatus = b.value.requestStatus; + + // sort available downloads first + if (aStatus == RequestStatus.available && + bStatus != RequestStatus.available) { + return -1; + } else if (aStatus != RequestStatus.available && + bStatus == RequestStatus.available) { + return 1; + } + + // then requestable users + if (aStatus == RequestStatus.unrequested && + bStatus != RequestStatus.unrequested) { + return -1; + } else if (aStatus != RequestStatus.unrequested && + bStatus == RequestStatus.unrequested) { + return 1; + } + + // then sort not found to the end + if (aStatus == RequestStatus.unavailable && + bStatus != RequestStatus.unavailable) { + return 1; + } else if (aStatus != RequestStatus.unavailable && + bStatus == RequestStatus.unavailable) { + return -1; + } + + return 0; + }); + return entries; + } + + String? get lastUpdatedString { + if (_lastUpdated == null) return null; + final now = DateTime.now(); + final difference = now.difference(_lastUpdated!); + + return difference.inDays > 0 + ? DateFormat('yyyy-MM-dd').format(_lastUpdated!) + : DateFormat('HH:mm a').format(_lastUpdated!); + } + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + final List futures = [ + GetStorage.init('analytics_request_storage'), + _loadProfiles(), + ]; + await Future.wait(futures); + + selectedLanguage = availableLanguages.contains(_userL2) + ? _userL2 + : availableLanguages.firstOrNull; + + await refresh(); + if (mounted) { + setState(() => initialized = true); + } + } + + Future _loadProfiles() async { + await room?.requestParticipants( + [Membership.join], + false, + true, + ); + + final futures = _availableUsers.map((u) async { + final resp = await MatrixState.pangeaController.userController + .getPublicProfile(u.id); + + _profiles[u] = resp; + if (resp.languageAnalytics == null) return; + + for (final lang in resp.languageAnalytics!.entries) { + if (lang.value.analyticsRoomId == null) continue; + _langsToUsers[lang.key] ??= []; + _langsToUsers[lang.key]!.add(u); + } + }); + + await Future.wait(futures); + } + + Future refresh() async { + if (room == null || !room!.isSpace || selectedLanguage == null) return; + + setState(() { + downloads = Map.fromEntries( + _availableUsers.map( + (user) { + final room = _analyticsRoomOfUser(user); + final hasLangData = _availableUsersForLang.contains(user); + + RequestStatus? requestStatus; + if (room != null) { + requestStatus = RequestStatus.available; + } else if (!hasLangData) { + requestStatus = RequestStatus.unavailable; + } else { + requestStatus = AnalyticsRequestsRepo.get( + user.id, + selectedLanguage!, + ) ?? + RequestStatus.unrequested; + } + + final DownloadStatus downloadStatus = + requestStatus == RequestStatus.available + ? DownloadStatus.loading + : DownloadStatus.unavailable; + + return MapEntry( + user, + AnalyticsDownload( + requestStatus: requestStatus, + downloadStatus: downloadStatus, + ), + ); + }, + ), + ); + }); + + for (final user in _availableUsers) { + final analyticsRoom = _analyticsRoomOfUser(user); + if (analyticsRoom == null) { + continue; + } + await _setAnalyticsModel(analyticsRoom); + } + + if (mounted) { + setState(() { + _lastUpdated = DateTime.now(); + }); + } + } + + Future _setAnalyticsModel( + Room analyticsRoom, + ) async { + final String? userID = analyticsRoom.creatorId; + final user = + room?.getParticipants().firstWhereOrNull((p) => p.id == userID); + if (user == null) return; + + SpaceAnalyticsSummaryModel? summary; + final constructEvents = await analyticsRoom.getAnalyticsEvents( + userId: userID!, + ); + + if (constructEvents == null) { + downloads[user] = AnalyticsDownload( + requestStatus: RequestStatus.available, + downloadStatus: DownloadStatus.complete, + summary: SpaceAnalyticsSummaryModel.emptyModel(userID), + ); + } else { + final List uses = []; + for (final event in constructEvents) { + uses.addAll(event.content.uses); + } + + final constructs = ConstructListModel(uses: uses); + summary = SpaceAnalyticsSummaryModel.fromConstructListModel( + userID, + constructs, + analyticsRoom.activityRoomIds.length, + (use) => + getGrammarCopy( + category: use.category, + lemma: use.lemma, + context: context, + ) ?? + use.lemma, + context, + ); + + downloads[user] = AnalyticsDownload( + requestStatus: RequestStatus.available, + downloadStatus: DownloadStatus.complete, + summary: summary, + ); + } + + if (mounted) setState(() {}); + } + + Future _requestAnalytics(User user) async { + RequestStatus? status = downloads[user]?.requestStatus; + if (status == RequestStatus.unavailable || + status == RequestStatus.available) { + return; + } + + try { + final roomId = _analyticsRoomIdOfUser(user); + if (roomId == null) return; + await Matrix.of(context).client.knockRoom(roomId); + status = RequestStatus.requested; + } catch (e) { + status = RequestStatus.unavailable; + if (!AnalyticsRequestsRepo.getAll().any( + (status) => status == RequestStatus.unavailable, + )) { + showDialog( + context: context, + builder: (_) { + return const SpaceAnalyticsInactiveDialog(); + }, + ); + } + } finally { + if (status != null) { + await AnalyticsRequestsRepo.set( + user.id, + selectedLanguage!, + status, + ); + + downloads[user]?.requestStatus = status; + } + + if (mounted) setState(() {}); + } + } + + Future requestAnalytics(User user) async { + final status = downloads[user]?.requestStatus; + if (status != RequestStatus.unrequested) return; + + await showFutureLoadingDialog( + context: context, + future: () => _requestAnalytics(user), + ); + } + + Future requestAllAnalytics() async { + final resp = await showDialog( + context: context, + builder: (_) { + return const SpaceAnalyticsRequestDialog(); + }, + ); + + if (resp != true) return; + final users = _availableUsersForLang + .where( + (user) => downloads[user]?.requestStatus == RequestStatus.unrequested, + ) + .toList(); + + final futures = users.map((user) => _requestAnalytics(user)); + await showFutureLoadingDialog( + context: context, + future: () => Future.wait(futures), + ); + } + + String? _analyticsRoomIdOfUser(User user) { + final profile = _profiles[user]; + if (profile == null || profile.languageAnalytics == null) return null; + + final entry = profile.languageAnalytics![selectedLanguage]; + return entry?.analyticsRoomId; + } + + Room? _analyticsRoomOfUser(User user) { + return Matrix.of(context).client.rooms.firstWhereOrNull( + (r) => + r.isAnalyticsRoomOfUser(user.id) && + r.madeForLang == selectedLanguage?.langCodeShort, + ); + } + + void setSelectedLanguage(LanguageModel? lang) { + selectedLanguage = lang; + refresh(); + } + + @override + Widget build(BuildContext context) => SpaceAnalyticsView(controller: this); +} diff --git a/lib/pangea/space_analytics/space_analytics_download_enum.dart b/lib/pangea/space_analytics/space_analytics_download_enum.dart new file mode 100644 index 000000000..ca8bd0f4e --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics_download_enum.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; + +/// The status of requests for space analytics access. +enum RequestStatus { + /// language analytics room is not in their profile + unavailable, + + /// pending response + requested, + + /// there is a room in their data but it hasn’t been requested + unrequested, + + /// the user is in the analytics room, and doesn’t need to request + available; + + static RequestStatus? fromString(String value) { + switch (value) { + case 'available': + return RequestStatus.available; + case 'unrequested': + return RequestStatus.unrequested; + case 'requested': + return RequestStatus.requested; + case 'unavailable': + return RequestStatus.unavailable; + default: + return null; + } + } + + IconData get icon { + switch (this) { + case RequestStatus.available: + return Icons.check_circle; + case RequestStatus.unrequested: + return Symbols.approval_delegation; + case RequestStatus.requested: + return Icons.mark_email_read_outlined; + case RequestStatus.unavailable: + return Symbols.approval_delegation; + } + } + + String label(BuildContext context) { + final l10n = L10n.of(context); + switch (this) { + case RequestStatus.available: + return l10n.available; + case RequestStatus.unrequested: + return l10n.request; + case RequestStatus.requested: + return l10n.pending; + case RequestStatus.unavailable: + return l10n.noDataFound; + } + } + + Color backgroundColor(BuildContext context) { + final theme = Theme.of(context); + switch (this) { + case RequestStatus.available: + case RequestStatus.unrequested: + return theme.colorScheme.primaryContainer; + case RequestStatus.unavailable: + case RequestStatus.requested: + return theme.disabledColor; + } + } + + bool get showButton => this != RequestStatus.available; + + bool get enabled => this == RequestStatus.unrequested; + + double get opacity => this == RequestStatus.unavailable ? 0.5 : 1.0; +} + +/// The status of the download process for space analytics data. +enum DownloadStatus { + /// The user is not in the analytics room, so the data cannot be downloaded. + unavailable, + + /// The data is being downloaded. + loading, + + /// The data has been downloaded successfully. + complete; +} diff --git a/lib/pangea/space_analytics/space_analytics_inactive_dialog.dart b/lib/pangea/space_analytics/space_analytics_inactive_dialog.dart new file mode 100644 index 000000000..4de0ad719 --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics_inactive_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; + +class SpaceAnalyticsInactiveDialog extends StatelessWidget { + const SpaceAnalyticsInactiveDialog({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return FullWidthDialog( + maxHeight: 375.0, + maxWidth: 450.0, + dialogContent: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 40.0, + ), + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + L10n.of(context).analyticsInactiveTitle, + style: const TextStyle(fontSize: 24.0), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + Text( + L10n.of(context).analyticsInactiveDesc, + style: const TextStyle(fontSize: 16.0), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/space_analytics/space_analytics_request_dialog.dart b/lib/pangea/space_analytics/space_analytics_request_dialog.dart new file mode 100644 index 000000000..45867d81a --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics_request_dialog.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; + +class SpaceAnalyticsRequestDialog extends StatelessWidget { + const SpaceAnalyticsRequestDialog({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + return FullWidthDialog( + maxHeight: 800.0, + maxWidth: 450.0, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + dialogContent: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).requestAccessTitle, + style: TextStyle(fontSize: isColumnMode ? 24.0 : 20.0), + ), + Text( + L10n.of(context).requestAccessDesc, + style: TextStyle(fontSize: isColumnMode ? 16.0 : 14.0), + ), + CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}", + errorWidget: (context, e, s) => const SizedBox.shrink(), + progressIndicatorBuilder: (context, _, __) => + const SizedBox.shrink(), + width: 150.0, + ), + const SizedBox(height: 50.0), + ], + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 20.0, + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + child: Row( + spacing: 10.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.close), + Text(L10n.of(context).cancel), + ], + ), + ), + ), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: Row( + spacing: 10.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Symbols.approval_delegation), + Text(L10n.of(context).requestAll), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/space_analytics/space_analytics_requested_dialog.dart b/lib/pangea/space_analytics/space_analytics_requested_dialog.dart new file mode 100644 index 000000000..c396720f7 --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics_requested_dialog.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; +import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; + +class SpaceAnalyticsRequestedDialog extends StatelessWidget { + final Room room; + const SpaceAnalyticsRequestedDialog({ + super.key, + required this.room, + }); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + return FullWidthDialog( + maxHeight: 800.0, + maxWidth: 450.0, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + dialogContent: Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + spacing: 12.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).accessRequestedTitle, + style: TextStyle(fontSize: isColumnMode ? 24.0 : 20.0), + ), + Text( + L10n.of(context).accessRequestedDesc( + room.getLocalizedDisplayname(), + ), + style: TextStyle(fontSize: isColumnMode ? 16.0 : 14.0), + ), + CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}", + errorWidget: (context, e, s) => const SizedBox.shrink(), + progressIndicatorBuilder: (context, _, __) => + const SizedBox.shrink(), + width: 150.0, + ), + const SizedBox(height: 50.0), + ], + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 16.0, + children: [ + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: Row( + spacing: 10.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Symbols.approval_delegation), + Text(L10n.of(context).allowAccess), + ], + ), + ), + ), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: Row( + spacing: 10.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.visibility_off), + Text(L10n.of(context).denyAccess), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/space_analytics/space_analytics_view.dart b/lib/pangea/space_analytics/space_analytics_view.dart new file mode 100644 index 000000000..8b8777e3b --- /dev/null +++ b/lib/pangea/space_analytics/space_analytics_view.dart @@ -0,0 +1,508 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; +import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart'; +import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class SpaceAnalyticsView extends StatelessWidget { + final SpaceAnalyticsState controller; + const SpaceAnalyticsView({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + final rowPadding = isColumnMode ? 12.0 : 4.0; + return Scaffold( + appBar: AppBar( + title: Text(L10n.of(context).spaceAnalyticsPage), + ), + body: Padding( + padding: EdgeInsets.all(isColumnMode ? 16.0 : 8.0), + child: MaxWidthBody( + maxWidth: 1000, + showBorder: false, + child: Column( + spacing: isColumnMode ? 24.0 : 12.0, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + spacing: isColumnMode ? 12.0 : 4.0, + children: [ + _MenuButton( + text: L10n.of(context).requestAll, + icon: Symbols.approval_delegation, + onPressed: controller.requestAllAnalytics, + ), + if (controller.room != null && + controller.availableAnalyticsRooms.isNotEmpty) + _MenuButton( + text: L10n.of(context).download, + icon: Icons.download, + onPressed: () { + showDialog( + context: context, + builder: (context) => DownloadAnalyticsDialog( + space: controller.room!, + analyticsRooms: + controller.availableAnalyticsRooms, + ), + ); + }, + mini: !isColumnMode, + ), + ], + ), + Row( + spacing: isColumnMode ? 12.0 : 4.0, + children: [ + if (controller.lastUpdatedString != null) + Text( + L10n.of(context).lastUpdated( + controller.lastUpdatedString!, + ), + textAlign: TextAlign.end, + style: TextStyle( + fontSize: isColumnMode ? 12.0 : 8.0, + color: theme.disabledColor, + ), + ), + _MenuButton( + text: L10n.of(context).refresh, + icon: Symbols.refresh, + onPressed: controller.refresh, + mini: !isColumnMode, + ), + DropdownButtonHideUnderline( + child: DropdownButton2( + customButton: Container( + height: isColumnMode ? 36.0 : 26.0, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(40), + ), + padding: EdgeInsets.symmetric( + horizontal: isColumnMode ? 8.0 : 4.0, + vertical: 4.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (controller.selectedLanguage != null) + Text( + controller.selectedLanguage! + .getDisplayName(context) ?? + controller + .selectedLanguage!.displayName, + style: TextStyle( + color: + theme.colorScheme.onPrimaryContainer, + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + Icon( + Icons.arrow_drop_down, + color: theme.colorScheme.onPrimaryContainer, + size: isColumnMode ? 24.0 : 14.0, + ), + ], + ), + ), + value: controller.selectedLanguage, + items: controller.availableLanguages + .map( + (item) => DropdownMenuItem( + value: item, + child: DropdownTextButton( + text: item.getDisplayName(context) ?? + item.displayName, + isSelected: false, + ), + ), + ) + .toList(), + onChanged: controller.setSelectedLanguage, + buttonStyleData: ButtonStyleData( + // This is necessary for the ink response to match our customButton radius. + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + ), + ), + dropdownStyleData: const DropdownStyleData( + offset: Offset(-50, 0), + width: 150, + ), + ), + ), + ], + ), + ], + ), + controller.initialized + ? Table( + columnWidths: const {0: FlexColumnWidth(2.5)}, + children: [ + TableRow( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.dividerColor), + ), + ), + children: [ + _TableHeaderCell( + text: L10n.of(context).viewingAnalytics( + controller.completedDownloads, + controller.downloads.length, + ), + icon: Icons.group_outlined, + ), + _TableHeaderCell( + text: L10n.of(context).level, + icon: Icons.star, + ), + _TableHeaderCell( + text: L10n.of(context).vocab, + icon: Symbols.dictionary, + ), + _TableHeaderCell( + text: L10n.of(context).grammar, + icon: Symbols.toys_and_games, + ), + _TableHeaderCell( + text: L10n.of(context).activities, + icon: Icons.radar, + ), + ], + ), + ...controller.sortedDownloads.mapIndexed( + (index, entry) { + final download = entry.value; + return TableRow( + children: [ + TableCell( + child: Opacity( + opacity: download.requestStatus.opacity, + child: Padding( + padding: EdgeInsets.symmetric( + vertical: rowPadding, + ), + child: Row( + spacing: isColumnMode ? 16.0 : 8.0, + children: [ + Avatar( + size: isColumnMode ? 64.0 : 40.0, + mxContent: entry.key.avatarUrl, + name: entry.key.calcDisplayname(), + userId: entry.key.id, + presenceUserId: entry.key.id, + ), + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + SizedBox( + height: + index == 0 ? 8.0 : 0.0, + ), + Text( + entry.key.id, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: TextStyle( + fontSize: isColumnMode + ? 16.0 + : 12.0, + fontWeight: FontWeight.w500, + ), + ), + _RequestButton( + status: + download.requestStatus, + onPressed: () => controller + .requestAnalytics( + entry.key, + ), + ), + const SizedBox(height: 8.0), + ], + ), + ), + ], + ), + ), + ), + ), + _TableContentCell( + text: download.summary?.level?.toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + ), + _TableContentCell( + text: download.summary?.numLemmas.toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + ), + _TableContentCell( + text: download.summary?.numMorphConstructs + .toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + ), + _TableContentCell( + text: download.summary?.numCompletedActivities + .toString(), + downloadStatus: download.downloadStatus, + requestStatus: download.requestStatus, + ), + ], + ); + }, + ), + ], + ) + : const CircularProgressIndicator.adaptive(), + ], + ), + ), + ), + ); + } +} + +class _MenuButton extends StatelessWidget { + final String text; + final IconData icon; + final VoidCallback onPressed; + + final bool mini; + + const _MenuButton({ + required this.text, + required this.icon, + required this.onPressed, + this.mini = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + final height = isColumnMode ? 36.0 : 26.0; + + return InkWell( + borderRadius: BorderRadius.circular(40), + onTap: onPressed, + child: Container( + height: height, + width: mini ? height : null, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(40), + ), + padding: EdgeInsets.symmetric( + horizontal: isColumnMode ? 8.0 : 4.0, + vertical: 4.0, + ), + child: mini + ? Icon( + icon, + color: theme.colorScheme.onPrimaryContainer, + size: isColumnMode ? 24.0 : 14.0, + ) + : Row( + spacing: 4.0, + children: [ + Icon( + icon, + color: theme.colorScheme.onPrimaryContainer, + size: isColumnMode ? 24.0 : 14.0, + ), + Text( + text, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: isColumnMode ? 16.0 : 12.0, + ), + ), + ], + ), + ), + ); + } +} + +class _TableHeaderCell extends StatelessWidget { + final String text; + final IconData icon; + const _TableHeaderCell({ + required this.text, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 6.0, + horizontal: 8.0, + ), + child: Column( + spacing: 10.0, + children: [ + Icon(icon, size: 22.0), + Text( + text, + style: TextStyle( + fontSize: isColumnMode ? 12.0 : 8.0, + ), + ), + ], + ), + ); + } +} + +class _TableContentCell extends StatelessWidget { + final String? text; + final DownloadStatus downloadStatus; + final RequestStatus requestStatus; + + const _TableContentCell({ + required this.text, + required this.downloadStatus, + required this.requestStatus, + }); + + @override + Widget build(BuildContext context) { + if (downloadStatus != DownloadStatus.complete) { + return _MissingContentCell( + downloadStatus, + requestStatus, + ); + } + + final isColumnMode = FluffyThemes.isColumnMode(context); + return TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Opacity( + opacity: requestStatus.opacity, + child: Container( + alignment: Alignment.center, + child: Text( + text!, + style: TextStyle( + fontSize: isColumnMode ? 16.0 : 12.0, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + } +} + +class _MissingContentCell extends StatelessWidget { + final DownloadStatus status; + final RequestStatus requestStatus; + + const _MissingContentCell( + this.status, + this.requestStatus, + ); + + @override + Widget build(BuildContext context) { + return TableCell( + verticalAlignment: TableCellVerticalAlignment.fill, + child: Opacity( + opacity: requestStatus.opacity, + child: Container( + alignment: Alignment.center, + child: status == DownloadStatus.loading + ? const SizedBox( + width: 16.0, + height: 16.0, + child: CircularProgressIndicator.adaptive(), + ) + : Icon( + requestStatus == RequestStatus.unavailable + ? Icons.block + : Icons.visibility_off_outlined, + size: 16.0, + ), + ), + ), + ); + } +} + +class _RequestButton extends StatelessWidget { + final RequestStatus status; + final VoidCallback onPressed; + + const _RequestButton({ + required this.status, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + if (!status.showButton) return const SizedBox.shrink(); + + final isColumnMode = FluffyThemes.isColumnMode(context); + + return MouseRegion( + cursor: status.enabled ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + onTap: status.enabled ? onPressed : null, + child: Opacity( + opacity: status.enabled ? 0.9 : 0.3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + color: status.backgroundColor(context), + ), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + status.icon, + size: isColumnMode ? 12.0 : 8.0, + ), + Text( + status.label(context), + style: TextStyle(fontSize: isColumnMode ? 12.0 : 8.0), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/spaces/widgets/download_space_analytics_dialog.dart b/lib/pangea/spaces/widgets/download_space_analytics_dialog.dart index 21c060506..812913f93 100644 --- a/lib/pangea/spaces/widgets/download_space_analytics_dialog.dart +++ b/lib/pangea/spaces/widgets/download_space_analytics_dialog.dart @@ -12,7 +12,6 @@ import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_mo import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; @@ -22,8 +21,10 @@ import 'package:fluffychat/widgets/matrix.dart'; class DownloadAnalyticsDialog extends StatefulWidget { final Room space; + final List analyticsRooms; const DownloadAnalyticsDialog({ required this.space, + required this.analyticsRooms, super.key, }); @@ -49,26 +50,10 @@ class DownloadAnalyticsDialogState extends State { } Future _initialize() async { - try { - await widget.space.requestParticipants( - [Membership.join], - false, - true, - ); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "spaceID": widget.space.id, - }, - ); - } finally { - _downloadStatuses = Map.fromEntries( - _usersToDownload.map((user) => MapEntry(user.id, 0)), - ); - if (mounted) setState(() => _initialized = true); - } + _downloadStatuses = Map.fromEntries( + widget.analyticsRooms.map((room) => MapEntry(room.creatorId!, 0)), + ); + if (mounted) setState(() => _initialized = true); } DownloadType _downloadType = DownloadType.csv; @@ -83,19 +68,10 @@ class DownloadAnalyticsDialogState extends State { _downloading = false; _downloaded = false; _downloadStatuses = Map.fromEntries( - _usersToDownload.map((user) => MapEntry(user.id, 0)), + widget.analyticsRooms.map((room) => MapEntry(room.creatorId!, 0)), ); } - List get _usersToDownload => widget.space - .getParticipants() - .where( - (member) => - member.id != BotName.byEnvironment && - member.membership == Membership.join, - ) - .toList(); - Color _downloadStatusColor(String userID) { final status = _downloadStatuses[userID]; if (status == 1) return Colors.yellow; @@ -122,15 +98,11 @@ class DownloadAnalyticsDialogState extends State { }); final List summaries = []; - await for (final batch - in widget.space.getNextAnalyticsRoomBatch(userL2!)) { - if (batch.isEmpty) continue; - final List batchSummaries = - await Future.wait( - batch.map((r) => _getAnalyticsModel(r)), - ); - summaries - .addAll(batchSummaries.whereType()); + for (final room in widget.analyticsRooms) { + final summary = await _getAnalyticsModel(room); + if (summary != null) { + summaries.add(summary); + } } for (final userID in _downloadStatuses.keys) { @@ -152,9 +124,7 @@ class DownloadAnalyticsDialogState extends State { ErrorHandler.logError( e: e, s: s, - data: { - "spaceID": widget.space.id, - }, + data: {}, ); _clean(); @@ -209,6 +179,7 @@ class DownloadAnalyticsDialogState extends State { summary = SpaceAnalyticsSummaryModel.fromConstructListModel( userID, constructs, + 0, getCopy, context, ); @@ -218,27 +189,12 @@ class DownloadAnalyticsDialogState extends State { e: e, s: s, data: { - "spaceID": widget.space.id, "userID": userID, }, ); if (mounted) setState(() => _downloadStatuses[userID] = -2); - } finally { - if (userID != widget.space.client.userID) { - try { - await analyticsRoom.leave(); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "spaceID": widget.space.id, - "userID": userID, - }, - ); - } - } } + return summary; } @@ -368,14 +324,14 @@ class DownloadAnalyticsDialogState extends State { ), child: ListView.builder( shrinkWrap: true, - itemCount: _usersToDownload.length, + itemCount: widget.analyticsRooms.length, itemBuilder: (context, index) { - final user = _usersToDownload[index]; + final userId = widget.analyticsRooms[index].creatorId; String tooltip = ""; - if (_downloadStatuses[user.id] == -1) { + if (_downloadStatuses[userId] == -1) { tooltip = L10n.of(context).analyticsNotAvailable; - } else if (_downloadStatuses[user.id] == -2) { + } else if (_downloadStatuses[userId] == -2) { tooltip = L10n.of(context).failedFetchUserAnalytics; } @@ -383,14 +339,13 @@ class DownloadAnalyticsDialogState extends State { padding: const EdgeInsets.all(4.0), child: AnimatedOpacity( duration: FluffyThemes.animationDuration, - opacity: - (_downloadStatuses[user.id] ?? 0) > 0 ? 1 : 0.5, + opacity: (_downloadStatuses[userId] ?? 0) > 0 ? 1 : 0.5, child: Row( children: [ SizedBox( width: 40, height: 30, - child: (_downloadStatuses[user.id] ?? 0) < 0 + child: (_downloadStatuses[userId] ?? 0) < 0 ? const Icon( Icons.error_outline, size: 16, @@ -402,7 +357,7 @@ class DownloadAnalyticsDialogState extends State { height: 12, width: 12, decoration: BoxDecoration( - color: _downloadStatusColor(user.id), + color: _downloadStatusColor(userId!), borderRadius: BorderRadius.circular(100), ), @@ -413,7 +368,7 @@ class DownloadAnalyticsDialogState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(user.displayName ?? user.id), + Text(userId!), if (tooltip.isNotEmpty) Text( tooltip, @@ -473,3 +428,480 @@ class DownloadAnalyticsDialogState extends State { ); } } + +// import 'package:flutter/material.dart'; + +// import 'package:csv/csv.dart'; +// import 'package:excel/excel.dart'; +// import 'package:matrix/matrix.dart'; + +// import 'package:fluffychat/config/app_config.dart'; +// import 'package:fluffychat/config/themes.dart'; +// import 'package:fluffychat/l10n/l10n.dart'; +// import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.dart'; +// import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart'; +// import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; +// import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +// import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +// import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +// import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; +// import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +// import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +// import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +// import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +// import 'package:fluffychat/widgets/matrix.dart'; + +// class DownloadAnalyticsDialog extends StatefulWidget { +// final Room space; +// const DownloadAnalyticsDialog({ +// required this.space, +// super.key, +// }); + +// @override +// DownloadAnalyticsDialogState createState() => DownloadAnalyticsDialogState(); +// } + +// class DownloadAnalyticsDialogState extends State { +// bool _initialized = false; +// bool _downloaded = false; +// bool _downloading = false; + +// bool get _loading => _downloading || !_initialized; + +// Object? _error; + +// Map _downloadStatuses = {}; + +// @override +// void initState() { +// super.initState(); +// _initialize(); +// } + +// Future _initialize() async { +// try { +// await widget.space.requestParticipants( +// [Membership.join], +// false, +// true, +// ); +// } catch (e, s) { +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// "spaceID": widget.space.id, +// }, +// ); +// } finally { +// _downloadStatuses = Map.fromEntries( +// _usersToDownload.map((user) => MapEntry(user.id, 0)), +// ); +// if (mounted) setState(() => _initialized = true); +// } +// } + +// DownloadType _downloadType = DownloadType.csv; + +// void _setDownloadType(DownloadType type) { +// _clean(); +// if (mounted) setState(() => _downloadType = type); +// } + +// void _clean() { +// _error = null; +// _downloading = false; +// _downloaded = false; +// _downloadStatuses = Map.fromEntries( +// _usersToDownload.map((user) => MapEntry(user.id, 0)), +// ); +// } + +// List get _usersToDownload => widget.space +// .getParticipants() +// .where( +// (member) => +// member.id != BotName.byEnvironment && +// member.membership == Membership.join, +// ) +// .toList(); + +// Color _downloadStatusColor(String userID) { +// final status = _downloadStatuses[userID]; +// if (status == 1) return Colors.yellow; +// if (status == 2) return Colors.green; +// if ((status ?? 0) < 0) return Colors.red; +// return Colors.grey; +// } + +// String? get _statusText { +// if (_downloading) return L10n.of(context).downloading; +// if (_downloaded) return L10n.of(context).downloadComplete; +// return null; +// } + +// String? get userL2 => +// MatrixState.pangeaController.languageController.userL2?.langCode; + +// Future _runDownload() async { +// try { +// if (!mounted || userL2 == null) return; +// setState(() { +// _error = null; +// _downloading = true; +// }); + +// final List summaries = []; +// await for (final batch +// in widget.space.getNextAnalyticsRoomBatch(userL2!)) { +// if (batch.isEmpty) continue; +// final List batchSummaries = +// await Future.wait( +// batch.map((r) => _getAnalyticsModel(r)), +// ); +// summaries +// .addAll(batchSummaries.whereType()); +// } + +// for (final userID in _downloadStatuses.keys) { +// if (_downloadStatuses[userID] == 0) { +// _downloadStatuses[userID] = -1; +// summaries.add(SpaceAnalyticsSummaryModel.emptyModel(userID)); +// } +// } + +// await _downloadSpaceAnalytics(summaries); + +// if (mounted) { +// setState(() { +// _downloading = false; +// _downloaded = true; +// }); +// } +// } catch (e, s) { +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// "spaceID": widget.space.id, +// }, +// ); + +// _clean(); +// _error = e; +// if (mounted) setState(() {}); +// } +// } + +// Future _downloadSpaceAnalytics( +// List summaries, +// ) async { +// final content = _downloadType == DownloadType.xlsx +// ? _getExcelFileContent(summaries) +// : _getCSVFileContent(summaries); + +// final fileName = +// "analytics_${widget.space.name}_${DateTime.now().toIso8601String()}.${_downloadType == DownloadType.xlsx ? 'xlsx' : 'csv'}"; + +// await downloadFile( +// content, +// fileName, +// DownloadType.csv, +// ); +// } + +// Future _getAnalyticsModel( +// Room analyticsRoom, +// ) async { +// final String? userID = analyticsRoom.creatorId; +// if (userID == null) return null; + +// SpaceAnalyticsSummaryModel? summary; +// try { +// _downloadStatuses[userID] = 1; +// if (mounted) setState(() {}); + +// final constructEvents = await analyticsRoom.getAnalyticsEvents( +// userId: userID, +// ); + +// if (constructEvents == null) { +// if (mounted) setState(() => _downloadStatuses[userID] = -1); +// return SpaceAnalyticsSummaryModel.emptyModel(userID); +// } + +// final List uses = []; +// for (final event in constructEvents) { +// uses.addAll(event.content.uses); +// } + +// final constructs = ConstructListModel(uses: uses); +// summary = SpaceAnalyticsSummaryModel.fromConstructListModel( +// userID, +// constructs, +// 0, +// getCopy, +// context, +// ); +// if (mounted) setState(() => _downloadStatuses[userID] = 2); +// } catch (e, s) { +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// "spaceID": widget.space.id, +// "userID": userID, +// }, +// ); +// if (mounted) setState(() => _downloadStatuses[userID] = -2); +// } finally { +// if (userID != widget.space.client.userID) { +// try { +// await analyticsRoom.leave(); +// } catch (e, s) { +// ErrorHandler.logError( +// e: e, +// s: s, +// data: { +// "spaceID": widget.space.id, +// "userID": userID, +// }, +// ); +// } +// } +// } +// return summary; +// } + +// List _formatExcelRow( +// SpaceAnalyticsSummaryModel summary, +// ) { +// final List row = []; +// for (int i = 0; i < SpaceAnalyticsSummaryEnum.values.length; i++) { +// final key = SpaceAnalyticsSummaryEnum.values[i]; +// final value = summary.getValue(key, context); +// if (value is int) { +// row.add(IntCellValue(value)); +// } else if (value is String) { +// row.add(TextCellValue(value)); +// } else if (value is List) { +// row.add(TextCellValue(value.join(", "))); +// } +// } +// return row; +// } + +// List _getExcelFileContent( +// List summaries, +// ) { +// final excel = Excel.createExcel(); +// final sheet = excel['Sheet1']; + +// for (final key in SpaceAnalyticsSummaryEnum.values) { +// sheet +// .cell( +// CellIndex.indexByColumnRow( +// rowIndex: 0, +// columnIndex: key.index, +// ), +// ) +// .value = TextCellValue(key.header(L10n.of(context))); +// } + +// final rows = summaries.map((summary) => _formatExcelRow(summary)).toList(); + +// for (int i = 0; i < rows.length; i++) { +// final row = rows[i]; +// for (int j = 0; j < row.length; j++) { +// final cell = row[j]; +// sheet +// .cell(CellIndex.indexByColumnRow(rowIndex: i + 2, columnIndex: j)) +// .value = cell; +// } +// } +// return excel.encode() ?? []; +// } + +// String _getCSVFileContent( +// List summaries, +// ) { +// final List> rows = []; +// final headerRow = []; +// for (final key in SpaceAnalyticsSummaryEnum.values) { +// headerRow.add(key.header(L10n.of(context))); +// } +// rows.add(headerRow); + +// for (final summary in summaries) { +// final row = []; +// for (int i = 0; i < SpaceAnalyticsSummaryEnum.values.length; i++) { +// final key = SpaceAnalyticsSummaryEnum.values[i]; +// final value = summary.getValue(key, context); +// if (value == null) continue; +// value is List ? row.add(value.join(", ")) : row.add(value); +// } +// rows.add(row); +// } + +// final String fileString = const ListToCsvConverter().convert(rows); +// return fileString; +// } + +// String getCopy(ConstructUses use) { +// return getGrammarCopy( +// category: use.category, +// lemma: use.lemma, +// context: context, +// ) ?? +// use.lemma; +// } + +// @override +// Widget build(BuildContext context) { +// return Dialog( +// child: Container( +// constraints: const BoxConstraints( +// maxWidth: 400, +// ), +// padding: const EdgeInsets.symmetric(vertical: 20), +// child: Column( +// mainAxisSize: MainAxisSize.min, +// children: [ +// Text( +// L10n.of(context).fileType, +// style: TextStyle( +// fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, +// ), +// ), +// Padding( +// padding: const EdgeInsets.all(8.0), +// child: SegmentedButton( +// selected: {_downloadType}, +// onSelectionChanged: (c) => _setDownloadType(c.first), +// segments: [ +// ButtonSegment( +// value: DownloadType.csv, +// label: Text(L10n.of(context).commaSeparatedFile), +// ), +// ButtonSegment( +// value: DownloadType.xlsx, +// label: Text(L10n.of(context).excelFile), +// ), +// ], +// ), +// ), +// Padding( +// padding: const EdgeInsets.all(8.0), +// child: ConstrainedBox( +// constraints: const BoxConstraints( +// maxHeight: 300, +// minHeight: 0, +// ), +// child: ListView.builder( +// shrinkWrap: true, +// itemCount: _usersToDownload.length, +// itemBuilder: (context, index) { +// final user = _usersToDownload[index]; + +// String tooltip = ""; +// if (_downloadStatuses[user.id] == -1) { +// tooltip = L10n.of(context).analyticsNotAvailable; +// } else if (_downloadStatuses[user.id] == -2) { +// tooltip = L10n.of(context).failedFetchUserAnalytics; +// } + +// return Padding( +// padding: const EdgeInsets.all(4.0), +// child: AnimatedOpacity( +// duration: FluffyThemes.animationDuration, +// opacity: +// (_downloadStatuses[user.id] ?? 0) > 0 ? 1 : 0.5, +// child: Row( +// children: [ +// SizedBox( +// width: 40, +// height: 30, +// child: (_downloadStatuses[user.id] ?? 0) < 0 +// ? const Icon( +// Icons.error_outline, +// size: 16, +// ) +// : Center( +// child: AnimatedContainer( +// duration: +// FluffyThemes.animationDuration, +// height: 12, +// width: 12, +// decoration: BoxDecoration( +// color: _downloadStatusColor(user.id), +// borderRadius: +// BorderRadius.circular(100), +// ), +// ), +// ), +// ), +// Flexible( +// child: Column( +// crossAxisAlignment: CrossAxisAlignment.start, +// children: [ +// Text(user.displayName ?? user.id), +// if (tooltip.isNotEmpty) +// Text( +// tooltip, +// style: const TextStyle(fontSize: 10), +// ), +// ], +// ), +// ), +// ], +// ), +// ), +// ); +// }, +// ), +// ), +// ), +// Padding( +// padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0), +// child: OutlinedButton( +// onPressed: _loading || !_initialized ? null : _runDownload, +// child: _initialized && !_loading +// ? Text( +// _loading +// ? L10n.of(context).downloading +// : L10n.of(context).download, +// ) +// : const SizedBox( +// height: 10, +// width: 100, +// child: LinearProgressIndicator(), +// ), +// ), +// ), +// AnimatedSize( +// duration: FluffyThemes.animationDuration, +// child: _statusText != null +// ? Padding( +// padding: const EdgeInsets.all(8.0), +// child: Text(_statusText!), +// ) +// : const SizedBox(), +// ), +// AnimatedSize( +// duration: FluffyThemes.animationDuration, +// child: _error != null +// ? Padding( +// padding: const EdgeInsets.all(8.0), +// child: ErrorIndicator( +// message: L10n.of(context).errorDownloading, +// ), +// ) +// : const SizedBox(), +// ), +// ], +// ), +// ), +// ); +// } +// } diff --git a/lib/pangea/user/controllers/user_controller.dart b/lib/pangea/user/controllers/user_controller.dart index 22332084e..460fbac6a 100644 --- a/lib/pangea/user/controllers/user_controller.dart +++ b/lib/pangea/user/controllers/user_controller.dart @@ -4,11 +4,13 @@ import 'package:collection/collection.dart'; import 'package:jwt_decode/jwt_decode.dart'; import 'package:matrix/matrix.dart' as matrix; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; @@ -57,7 +59,7 @@ class UserController { StreamSubscription? _profileListener; /// Listen for updates to account data in syncs and update the cached profile - void addProfileListener() { + void _addProfileListener() { _profileListener ??= client.onSync.stream .where((sync) => sync.accountData != null) .listen((sync) { @@ -144,7 +146,9 @@ class UserController { try { await _initialize(); - addProfileListener(); + _addProfileListener(); + _addAnalyticsRoomIdsToPublicProfile(); + if (profile.userSettings.targetLanguage != null && profile.userSettings.targetLanguage!.isNotEmpty && _pangeaController.languageController.userL2 == null) { @@ -224,15 +228,53 @@ class UserController { baseLanguage ??= _pangeaController.languageController.userL1; if (targetLanguage == null || publicProfile == null) return; + final analyticsRoom = + _pangeaController.matrixState.client.analyticsRoomLocal(targetLanguage); + if (publicProfile!.targetLanguage == targetLanguage && publicProfile!.baseLanguage == baseLanguage && - publicProfile!.languageAnalytics?[targetLanguage]?.level == level) { + publicProfile!.languageAnalytics?[targetLanguage]?.level == level && + publicProfile!.analyticsRoomIdByLanguage(targetLanguage) == + analyticsRoom?.id) { return; } publicProfile!.baseLanguage = baseLanguage; publicProfile!.targetLanguage = targetLanguage; - publicProfile!.setLevel(targetLanguage, level); + publicProfile!.setLanguageInfo( + targetLanguage, + level, + analyticsRoom?.id, + ); + await _savePublicProfile(); + } + + Future _addAnalyticsRoomIdsToPublicProfile() async { + if (publicProfile?.languageAnalytics == null) return; + final analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + if (analyticsRooms.isEmpty) return; + for (final analyticsRoom in analyticsRooms) { + final lang = analyticsRoom.madeForLang?.split("-").first; + if (lang == null || publicProfile?.languageAnalytics == null) continue; + final langKey = publicProfile!.languageAnalytics!.keys.firstWhereOrNull( + (l) => l.langCodeShort == lang, + ); + + if (langKey == null) continue; + if (publicProfile!.languageAnalytics![langKey]!.analyticsRoomId == + analyticsRoom.id) { + continue; + } + + publicProfile!.setLanguageInfo( + langKey, + publicProfile!.languageAnalytics![langKey]!.level, + analyticsRoom.id, + ); + } + await _savePublicProfile(); } @@ -240,7 +282,13 @@ class UserController { final targetLanguage = _pangeaController.languageController.userL2; if (targetLanguage == null || publicProfile == null) return; - publicProfile!.addXPOffset(targetLanguage, offset); + publicProfile!.addXPOffset( + targetLanguage, + offset, + _pangeaController.matrixState.client + .analyticsRoomLocal(targetLanguage) + ?.id, + ); await _savePublicProfile(); } diff --git a/lib/pangea/user/models/profile_model.dart b/lib/pangea/user/models/profile_model.dart index e89d41e00..5bc8e3d5d 100644 --- a/lib/pangea/user/models/profile_model.dart +++ b/lib/pangea/user/models/profile_model.dart @@ -37,8 +37,13 @@ class PublicProfileModel { if (lang == null) continue; final level = entry.value[ModelKey.level]; final xpOffset = entry.value[ModelKey.xpOffset] ?? 0; - languageAnalytics[lang] = - LanguageAnalyticsProfileEntry(level, xpOffset); + final analyticsRoomId = + entry.value[ModelKey.analyticsRoomId] as String?; + languageAnalytics[lang] = LanguageAnalyticsProfileEntry( + level, + xpOffset, + analyticsRoomId: analyticsRoomId, + ); } } @@ -67,6 +72,8 @@ class PublicProfileModel { analytics[entry.key.langCode] = { ModelKey.level: entry.value.level, ModelKey.xpOffset: entry.value.xpOffset, + if (entry.value.analyticsRoomId != null) + ModelKey.analyticsRoomId: entry.value.analyticsRoomId, }; } } @@ -80,15 +87,49 @@ class PublicProfileModel { targetLanguage == null || (languageAnalytics == null || languageAnalytics!.isEmpty); - void setLevel(LanguageModel language, int level) { + String? analyticsRoomIdByLanguage(LanguageModel language) => + languageAnalytics![language]?.analyticsRoomId; + + /// Set the level and analytics room ID for the a given language. + void setLanguageInfo( + LanguageModel language, + int level, + String? analyticsRoomId, + ) { languageAnalytics ??= {}; - languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(0, 0); + languageAnalytics![language] ??= LanguageAnalyticsProfileEntry( + 0, + 0, + analyticsRoomId: analyticsRoomId, + ); + + if (languageAnalytics![language]!.level < level) { + languageAnalytics![language]!.level = level; + } + + final currentRoomId = analyticsRoomIdByLanguage(language); + if (currentRoomId == null) { + languageAnalytics![language]!.analyticsRoomId = analyticsRoomId; + } languageAnalytics![language]!.level = level; } - void addXPOffset(LanguageModel language, int xpOffset) { + void addXPOffset( + LanguageModel language, + int xpOffset, + String? analyticsRoomId, + ) { languageAnalytics ??= {}; - languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(0, 0); + languageAnalytics![language] ??= LanguageAnalyticsProfileEntry( + 0, + 0, + analyticsRoomId: analyticsRoomId, + ); + + final currentRoomId = analyticsRoomIdByLanguage(language); + if (currentRoomId == null) { + languageAnalytics![language]!.analyticsRoomId = analyticsRoomId; + } languageAnalytics![language]!.xpOffset += xpOffset; } @@ -100,6 +141,11 @@ class PublicProfileModel { class LanguageAnalyticsProfileEntry { int level; int xpOffset = 0; + String? analyticsRoomId; - LanguageAnalyticsProfileEntry(this.level, this.xpOffset); + LanguageAnalyticsProfileEntry( + this.level, + this.xpOffset, { + this.analyticsRoomId, + }); }