From d8caf8e481ba1c188bc7bc06b9b942bb75c27ddd Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:35:41 -0500 Subject: [PATCH] feat: analytics database --- lib/pages/chat/chat.dart | 78 +- lib/pages/chat/events/html_message.dart | 4 - .../activity_room_extension.dart | 45 -- .../activity_chat_controller.dart | 57 +- .../activity_finished_status_message.dart | 4 +- .../activity_menu_button.dart | 8 +- .../analytics_data_service.dart | 456 ++++++++++++ .../analytics_data/analytics_database.dart | 698 ++++++++++++++++++ .../analytics_database_builder.dart | 95 +++ .../analytics_sync_controller.dart | 85 +++ .../analytics_update_dispatcher.dart | 140 ++++ .../analytics_update_events.dart | 25 + .../analytics_update_service.dart | 164 ++++ .../analytics_updater_mixin.dart | 41 + .../analytics_data/construct_merge_table.dart | 140 ++++ .../derived_analytics_data_model.dart | 109 +++ .../level_up_analytics_service.dart | 106 +++ .../analytics_details_popup.dart | 41 + .../morph_analytics_list_view.dart | 65 +- .../morph_analytics_xp_tile.dart | 62 -- .../morph_details_view.dart | 74 +- .../vocab_analytics_details_view.dart | 151 ++-- .../vocab_analytics_list_tile.dart | 33 +- .../vocab_analytics_list_view.dart | 125 ++-- .../vocab_details_emoji_selector.dart | 69 -- .../analytics_dowload_dialog.dart | 13 +- .../space_analytics_summary_model.dart | 432 ++++++++--- .../analytics_misc/construct_list_model.dart | 370 ---------- .../analytics_misc/construct_use_model.dart | 65 +- .../analytics_misc/constructs_model.dart | 22 + .../get_analytics_controller.dart | 650 ---------------- .../lemma_emoji_setter_mixin.dart | 26 +- lib/pangea/analytics_misc/level_summary.dart | 87 --- .../level_summary_extension.dart | 27 + .../level_up/level_popup_progess_bar.dart | 2 +- .../level_up/level_up_banner.dart | 18 +- .../level_up/level_up_manager.dart | 31 +- .../message_analytics_feedback.dart | 1 - .../put_analytics_controller.dart | 330 --------- .../room_analytics_extension.dart | 279 +------ .../saved_analytics_extension.dart | 48 ++ .../user_lemma_info_extension.dart | 49 ++ .../analytics_page/activity_archive.dart | 7 +- lib/pangea/analytics_page/analytics_page.dart | 133 ++-- .../analytics_settings_extension.dart | 10 +- .../animated_progress_bar.dart | 0 .../learning_progress_bar.dart | 11 +- .../learning_progress_indicators.dart | 402 +++++----- .../level_analytics_details_content.dart | 156 ++++ .../level_dialog_content.dart | 153 ---- .../progress_indicators_enum.dart | 2 - lib/pangea/authentication/p_logout.dart | 6 +- .../common/controllers/pangea_controller.dart | 36 +- .../constructs/construct_identifier.dart | 74 +- .../events/models/pangea_token_model.dart | 61 -- .../extensions/pangea_room_extension.dart | 3 - .../extensions/room_events_extension.dart | 69 +- .../lemmas/lemma_highlight_emoji_row.dart | 38 +- lib/pangea/lemmas/user_set_lemma_info.dart | 3 +- .../emoji_activity_generator.dart | 9 +- .../lemma_activity_generator.dart | 12 +- .../practice_selection_repo.dart | 111 ++- .../download_space_analytics_dialog.dart | 20 +- .../space_analytics/space_analytics.dart | 108 +-- .../message_practice/practice_controller.dart | 47 +- .../token_practice_button.dart | 2 +- .../toolbar/message_selection_overlay.dart | 10 +- .../select_mode_controller.dart | 10 - .../token_emoji_button.dart | 203 +++-- .../reading_assistance/tokens_util.dart | 8 +- .../word_card/lemma_reaction_picker.dart | 111 ++- .../word_card/reading_assistance_content.dart | 4 - .../toolbar/word_card/word_zoom_widget.dart | 3 - lib/pangea/user/user_controller.dart | 25 +- lib/utils/client_manager.dart | 11 - lib/widgets/matrix.dart | 27 +- pubspec.lock | 16 +- 77 files changed, 3818 insertions(+), 3408 deletions(-) create mode 100644 lib/pangea/analytics_data/analytics_data_service.dart create mode 100644 lib/pangea/analytics_data/analytics_database.dart create mode 100644 lib/pangea/analytics_data/analytics_database_builder.dart create mode 100644 lib/pangea/analytics_data/analytics_sync_controller.dart create mode 100644 lib/pangea/analytics_data/analytics_update_dispatcher.dart create mode 100644 lib/pangea/analytics_data/analytics_update_events.dart create mode 100644 lib/pangea/analytics_data/analytics_update_service.dart create mode 100644 lib/pangea/analytics_data/analytics_updater_mixin.dart create mode 100644 lib/pangea/analytics_data/construct_merge_table.dart create mode 100644 lib/pangea/analytics_data/derived_analytics_data_model.dart create mode 100644 lib/pangea/analytics_data/level_up_analytics_service.dart delete mode 100644 lib/pangea/analytics_details_popup/morph_analytics_xp_tile.dart delete mode 100644 lib/pangea/analytics_details_popup/vocab_details_emoji_selector.dart delete mode 100644 lib/pangea/analytics_misc/construct_list_model.dart delete mode 100644 lib/pangea/analytics_misc/get_analytics_controller.dart delete mode 100644 lib/pangea/analytics_misc/level_summary.dart create mode 100644 lib/pangea/analytics_misc/level_summary_extension.dart delete mode 100644 lib/pangea/analytics_misc/put_analytics_controller.dart create mode 100644 lib/pangea/analytics_misc/saved_analytics_extension.dart create mode 100644 lib/pangea/analytics_misc/user_lemma_info_extension.dart rename lib/pangea/analytics_summary/{progress_bar => }/animated_progress_bar.dart (100%) create mode 100644 lib/pangea/analytics_summary/level_analytics_details_content.dart delete mode 100644 lib/pangea/analytics_summary/level_dialog_content.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index a56140985..7e2f95aea 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -30,10 +30,11 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; import 'package:fluffychat/pangea/analytics_misc/message_analytics_feedback.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; @@ -50,6 +51,7 @@ import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; @@ -179,14 +181,14 @@ class ChatPageWithRoom extends StatefulWidget { } class ChatController extends State - with WidgetsBindingObserver { + with WidgetsBindingObserver, AnalyticsUpdater { // #Pangea final PangeaController pangeaController = MatrixState.pangeaController; late Choreographer choreographer; late GoRouter _router; StreamSubscription? _levelSubscription; - StreamSubscription? _analyticsSubscription; + StreamSubscription? _constructsSubscription; StreamSubscription? _botAudioSubscription; final timelineUpdateNotifier = _TimelineUpdateNotifier(); late final ActivityChatController activityController; @@ -454,25 +456,20 @@ class ChatController extends State } // #Pangea - void _onLevelUp(dynamic update) { - if (update['level_up'] != null) { - LevelUpUtil.showLevelUpDialog( - update['upper_level'], - update['lower_level'], - context, - ); - } else if (update['unlocked_constructs'] != null) { - ConstructNotificationUtil.addUnlockedConstruct( - List.from(update['unlocked_constructs']), - context, - ); - } + void _onLevelUp(LevelUpdate update) { + LevelUpUtil.showLevelUpDialog( + update.newLevel, + update.prevLevel, + context, + ); } - void _onAnalyticsUpdate(AnalyticsStreamUpdate update) { - if (update.targetID != null) { - OverlayUtil.showPointsGained(update.targetID!, update.points, context); - } + void _onUnlockConstructs(Set constructs) { + if (constructs.isEmpty) return; + ConstructNotificationUtil.addUnlockedConstruct( + List.from(constructs), + context, + ); } Future _botAudioListener(SyncUpdate update) async { @@ -517,18 +514,18 @@ class ChatController extends State void _pangeaInit() { choreographer = Choreographer(inputFocus); - _levelSubscription = - pangeaController.getAnalytics.stateStream.listen(_onLevelUp); + final updater = Matrix.of(context).analyticsDataService.updateDispatcher; - _analyticsSubscription = pangeaController - .getAnalytics.analyticsStream.stream - .listen(_onAnalyticsUpdate); + _levelSubscription = updater.levelUpdateStream.stream.listen(_onLevelUp); + + _constructsSubscription = + updater.unlockedConstructsStream.stream.listen(_onUnlockConstructs); _botAudioSubscription = room.client.onSync.stream.listen(_botAudioListener); activityController = ActivityChatController( userID: Matrix.of(context).client.userID!, - getAnalytics: room.getActivityAnalytics, + room: room, ); Future.delayed(const Duration(seconds: 1), () async { @@ -785,8 +782,8 @@ class ChatController extends State MatrixState.pAnyState.closeAllOverlays(force: true); stopMediaStream.close(); _levelSubscription?.cancel(); - _analyticsSubscription?.cancel(); _botAudioSubscription?.cancel(); + _constructsSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); scrollController.dispose(); inputFocus.dispose(); @@ -2118,12 +2115,7 @@ class ChatController extends State ]; _showAnalyticsFeedback(constructs, eventId); - pangeaController.putAnalytics.addAnalytics( - constructs, - eventId: eventId, - targetId: eventId, - roomId: room.id, - ); + addAnalytics(constructs, eventId); } } @@ -2169,12 +2161,10 @@ class ChatController extends State if (constructs.isEmpty) return; _showAnalyticsFeedback(constructs, eventId); - MatrixState.pangeaController.putAnalytics.addAnalytics( - constructs, - eventId: eventId, - targetId: eventId, - roomId: room.id, - ); + Matrix.of(context).analyticsDataService.updateService.addAnalytics( + eventId, + constructs, + ); } catch (e, s) { ErrorHandler.logError( e: e, @@ -2241,17 +2231,17 @@ class ChatController extends State ); } - void _showAnalyticsFeedback( + Future _showAnalyticsFeedback( List constructs, String eventId, - ) { - final newGrammarConstructs = - pangeaController.getAnalytics.newConstructCount( + ) async { + final analyticsService = Matrix.of(context).analyticsDataService; + final newGrammarConstructs = await analyticsService.getNewConstructCount( constructs, ConstructTypeEnum.morph, ); - final newVocabConstructs = pangeaController.getAnalytics.newConstructCount( + final newVocabConstructs = await analyticsService.getNewConstructCount( constructs, ConstructTypeEnum.vocab, ); diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index c534ce5b5..c28e28c58 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -449,8 +449,6 @@ class HtmlMessage extends StatelessWidget { selectModeNotifier: overlayController!.selectedMode, onTap: () => overlayController!.onClickOverlayMessageToken(token), - constructEmojiNotifier: overlayController! - .selectModeController.constructEmojiNotifier, textColor: textColor, ), if (renderer.showCenterStyling && @@ -972,8 +970,6 @@ class HtmlMessage extends StatelessWidget { selectModeNotifier: overlayController!.selectedMode, onTap: () {}, enabled: false, - constructEmojiNotifier: overlayController! - .selectModeController.constructEmojiNotifier, textColor: textColor, ), RichText( diff --git a/lib/pangea/activity_sessions/activity_room_extension.dart b/lib/pangea/activity_sessions/activity_room_extension.dart index 84c616d20..19bd114b9 100644 --- a/lib/pangea/activity_sessions/activity_room_extension.dart +++ b/lib/pangea/activity_sessions/activity_room_extension.dart @@ -8,11 +8,9 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.dart'; -import 'package:fluffychat/pangea/activity_sessions/activity_session_analytics_repo.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_request_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/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; @@ -22,7 +20,6 @@ import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extensio import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import '../activity_summary/activity_summary_repo.dart'; class RoleException implements Exception { @@ -311,48 +308,6 @@ extension ActivityRoomExtension on Room { } } - Future getActivityAnalytics() async { - // wait for local storage box to init in getAnalytics initialization - if (!MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted) { - await MatrixState.pangeaController.getAnalytics.initCompleter.future; - } - - final cached = ActivitySessionAnalyticsRepo.get(id); - final analytics = cached?.analytics ?? ActivitySummaryAnalyticsModel(); - - DateTime? timestamp = creationTimestamp; - if (cached != null) { - timestamp = cached.lastUseTimestamp; - } - - final List uses = []; - - for (final use - in MatrixState.pangeaController.getAnalytics.constructListModel.uses) { - final useTimestamp = use.metadata.timeStamp; - if (timestamp != null && - (useTimestamp == timestamp || useTimestamp.isBefore(timestamp))) { - break; - } - - if (use.metadata.roomId != id) continue; - uses.add(use); - } - - if (uses.isEmpty) { - return analytics; - } - - analytics.addConstructs(client.userID!, uses); - await ActivitySessionAnalyticsRepo.set( - id, - uses.first.metadata.timeStamp, - analytics, - ); - - return analytics; - } - // UI-related helper functions bool get showActivityChatUI { diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart index eb2575efc..d5a38e17b 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart @@ -2,19 +2,24 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_session_analytics_repo.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ActivityChatController { final String userID; - final Future Function()? getAnalytics; + final Room room; ActivityChatController({ required this.userID, - required this.getAnalytics, + required this.room, }) { init(); } @@ -30,14 +35,10 @@ class ActivityChatController { final ValueNotifier hasRainedConfetti = ValueNotifier(false); void init() { - if (getAnalytics != null) { - _updateUsedVocab(); - _analyticsSubscription = MatrixState - .pangeaController.getAnalytics.analyticsStream.stream - .listen((_) { - _updateUsedVocab(); - }); - } + _updateUsedVocab(); + _analyticsSubscription = MatrixState.pangeaController.matrixState + .analyticsDataService.updateDispatcher.constructUpdateStream.stream + .listen((_) => _updateUsedVocab()); } void dispose() { @@ -76,10 +77,8 @@ class ActivityChatController { } Future _updateUsedVocab() async { - if (getAnalytics == null) return; - try { - final analytics = await getAnalytics!.call(); + final analytics = await getActivityAnalytics(); if (!_disposed) { usedVocab.value = analytics.constructs[userID] ?.constructsOfType(ConstructTypeEnum.vocab) @@ -97,4 +96,36 @@ class ActivityChatController { ); } } + + Future getActivityAnalytics() async { + final cached = ActivitySessionAnalyticsRepo.get(room.id); + final analytics = cached?.analytics ?? ActivitySummaryAnalyticsModel(); + + DateTime? timestamp = room.creationTimestamp; + if (cached != null) { + timestamp = cached.lastUseTimestamp; + } + + List uses = []; + final analyticsService = + MatrixState.pangeaController.matrixState.analyticsDataService; + + uses = await analyticsService.getUses( + since: timestamp ?? DateTime.fromMillisecondsSinceEpoch(0), + roomId: room.id, + ); + + if (uses.isEmpty) { + return analytics; + } + + analytics.addConstructs(userID, uses); + await ActivitySessionAnalyticsRepo.set( + room.id, + uses.first.metadata.timeStamp, + analytics, + ); + + return analytics; + } } diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart index b87b4def7..9911c1130 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart @@ -36,7 +36,9 @@ class ActivityFinishedStatusMessage extends StatelessWidget { Future _archiveToAnalytics(BuildContext context) async { await controller.room.archiveActivity(); - await MatrixState.pangeaController.putAnalytics + await Matrix.of(context) + .analyticsDataService + .updateService .sendActivityAnalytics(controller.room.id); } diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_menu_button.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_menu_button.dart index c1af0d4d1..ffb7cb9af 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_menu_button.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_menu_button.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/common/widgets/tutorial_overlay_message.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class ActivityMenuButton extends StatefulWidget { final ChatController controller; @@ -34,8 +35,11 @@ class _ActivityMenuButtonState extends State { void initState() { super.initState(); - _analyticsSubscription = widget - .controller.pangeaController.getAnalytics.analyticsStream.stream + _analyticsSubscription = Matrix.of(context) + .analyticsDataService + .updateDispatcher + .constructUpdateStream + .stream .listen(_showStatsMenuDropdownInstructions); _rolesSubscription = widget.controller.room.client.onRoomState.stream diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart new file mode 100644 index 000000000..1dd8b20b6 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -0,0 +1,456 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_data/analytics_database.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_database_builder.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_sync_controller.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart'; +import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart'; +import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; +import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/languages/language_model.dart'; +import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class _AnalyticsClient { + final Client client; + final AnalyticsDatabase database; + + _AnalyticsClient({ + required this.client, + required this.database, + }); +} + +class AnalyticsStreamUpdate { + final int points; + final ConstructIdentifier? blockedConstruct; + final String? targetID; + + AnalyticsStreamUpdate({ + this.points = 0, + this.blockedConstruct, + this.targetID, + }); +} + +class AnalyticsDataService { + _AnalyticsClient? _analyticsClient; + + late final AnalyticsUpdateDispatcher updateDispatcher; + late final AnalyticsUpdateService updateService; + late final LevelUpAnalyticsService levelUpService; + AnalyticsSyncController? _syncController; + final ConstructMergeTable _mergeTable = ConstructMergeTable(); + + Completer _initCompleter = Completer(); + + AnalyticsDataService(Client client) { + updateDispatcher = AnalyticsUpdateDispatcher(this); + updateService = AnalyticsUpdateService(this); + levelUpService = LevelUpAnalyticsService( + client: client, + ensureInitialized: () => _ensureInitialized(), + dataService: this, + ); + _initDatabase(client); + } + + static const int _morphUnlockXP = 30; + + int _cacheVersion = 0; + int _derivedCacheVersion = -1; + DerivedAnalyticsDataModel? _cachedDerivedStats; + + _AnalyticsClient get _analyticsClientGetter { + assert(_analyticsClient != null); + return _analyticsClient!; + } + + bool get isInitializing => !_initCompleter.isCompleted; + + Future getAnalyticsRoom(LanguageModel l2) => + _analyticsClientGetter.client.getMyAnalyticsRoom(l2); + + void dispose() { + _syncController?.dispose(); + updateDispatcher.dispose(); + _closeDatabase(); + } + + void _invalidateCaches() { + _cacheVersion++; + _cachedDerivedStats = null; + } + + Future _initDatabase(Client client) async { + _invalidateCaches(); + + final database = await analyticsDatabaseBuilder( + "${client.clientName}_analytics", + ); + _analyticsClient = _AnalyticsClient(client: client, database: database); + + if (client.isLogged()) { + await _initAnalytics(); + } else { + await client.onLoginStateChanged.stream.firstWhere( + (state) => state == LoginState.loggedIn, + ); + await _initAnalytics(); + } + } + + Future _initAnalytics() async { + try { + final client = _analyticsClientGetter.client; + if (client.prevBatch == null) { + await client.onSync.stream.first; + } + + _invalidateCaches(); + await _clearDatabase(); + + final resp = await client.getUserProfile(client.userID!); + final analyticsProfile = + AnalyticsProfileModel.fromJson(resp.additionalProperties); + + _syncController?.dispose(); + _syncController = AnalyticsSyncController( + client: client, + dataService: this, + ); + + await updateXPOffset(analyticsProfile.xpOffset ?? 0); + + await _syncController!.bulkUpdate(); + _syncController!.start(); + + await _initMergeTable(); + } catch (e, s) { + Logs().e("Error initializing analytics: $e, $s"); + } finally { + Logs().i("Analytics database initialized."); + _initCompleter.complete(); + updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([])); + } + } + + Future _initMergeTable() async { + final vocab = await _analyticsClientGetter.database + .getAggregatedConstructs(ConstructTypeEnum.vocab); + final morph = await _analyticsClientGetter.database + .getAggregatedConstructs(ConstructTypeEnum.morph); + + final blocked = blockedConstructs; + _mergeTable.addConstructs(vocab, blocked); + _mergeTable.addConstructs(morph, blocked); + } + + Future reinitialize() async { + Logs().i("Reinitializing analytics database."); + _initCompleter = Completer(); + await _initDatabase(_analyticsClientGetter.client); + } + + Future _clearDatabase() async { + await _analyticsClient?.database.clear(); + _invalidateCaches(); + _mergeTable.clear(); + } + + Future _closeDatabase() async { + await _analyticsClient?.database.delete(); + _analyticsClient = null; + _invalidateCaches(); + _mergeTable.clear(); + } + + Future _ensureInitialized() => + _initCompleter.isCompleted ? Future.value() : _initCompleter.future; + + int numConstructs(ConstructTypeEnum type) => + _mergeTable.uniqueConstructsByType(type); + + bool hasUsedConstruct(ConstructIdentifier id) => + _mergeTable.constructUsed(id); + + int uniqueConstructsByType(ConstructTypeEnum type) => + _mergeTable.uniqueConstructsByType(type); + + Set get blockedConstructs { + final analyticsRoom = _analyticsClientGetter.client.analyticsRoomLocal(); + return analyticsRoom?.blockedConstructs ?? {}; + } + + Future waitForSync() async { + await _syncController?.syncStream.stream.first; + } + + Future get derivedData async { + await _ensureInitialized(); + + if (_cachedDerivedStats == null || _derivedCacheVersion != _cacheVersion) { + _cachedDerivedStats = + await _analyticsClientGetter.database.getDerivedStats(); + _derivedCacheVersion = _cacheVersion; + } + + return _cachedDerivedStats!; + } + + Future getLastUpdatedAnalytics() async { + return _analyticsClientGetter.database.getLastEventTimestamp(); + } + + Future> getUses({ + int? count, + String? roomId, + DateTime? since, + }) async { + await _ensureInitialized(); + final uses = await _analyticsClientGetter.database.getUses( + count: count, + roomId: roomId, + since: since, + ); + + final blocked = blockedConstructs; + return uses.where((use) => !blocked.contains(use.identifier)).toList(); + } + + Future> getLocalUses() async { + await _ensureInitialized(); + return _analyticsClientGetter.database.getLocalUses(); + } + + Future getLocalConstructCount() async { + await _ensureInitialized(); + return _analyticsClientGetter.database.getLocalConstructCount(); + } + + Future getConstructUse(ConstructIdentifier id) async { + await _ensureInitialized(); + final blocked = blockedConstructs; + final ids = _mergeTable.groupedIds(id, blocked); + if (ids.isEmpty) { + return ConstructUses( + uses: [], + constructType: id.type, + lemma: id.lemma, + category: id.category, + ); + } + + return _analyticsClientGetter.database.getConstructUse(ids); + } + + Future> getConstructUses( + List ids, + ) async { + await _ensureInitialized(); + final Map> request = {}; + final blocked = blockedConstructs; + for (final id in ids) { + if (blocked.contains(id)) continue; + request[id] = _mergeTable.groupedIds(id, blocked); + } + + return _analyticsClientGetter.database.getConstructUses(request); + } + + Future> getAggregatedConstructs( + ConstructTypeEnum type, + ) async { + await _ensureInitialized(); + final combined = + await _analyticsClientGetter.database.getAggregatedConstructs(type); + + final stopwatch = Stopwatch()..start(); + + final cleaned = {}; + final blocked = blockedConstructs; + for (final entry in combined) { + final canonical = _mergeTable.resolve(entry.id); + + // Insert or merge + final existing = cleaned[canonical]; + if (existing != null) { + existing.merge(entry); + } else if (!blocked.contains(canonical)) { + cleaned[canonical] = entry; + } + } + + stopwatch.stop(); + Logs().i( + "Merging analytics took: ${stopwatch.elapsedMilliseconds} ms, total constructs: ${cleaned.length}", + ); + + return cleaned; + } + + Future getNewConstructCount( + List newConstructs, + ConstructTypeEnum type, + ) async { + await _ensureInitialized(); + final blocked = blockedConstructs; + final uses = newConstructs + .where( + (c) => c.constructType == type && !blocked.contains(c.identifier), + ) + .toList(); + + final Map constructPoints = {}; + for (final use in uses) { + constructPoints[use.identifier] ??= 0; + constructPoints[use.identifier] = + constructPoints[use.identifier]! + use.xp; + } + + final constructs = await getConstructUses(constructPoints.keys.toList()); + + int newConstructCount = 0; + for (final entry in constructPoints.entries) { + final construct = constructs[entry.key]!; + if (construct.points == entry.value) { + newConstructCount++; + } + } + + return newConstructCount; + } + + Future updateXPOffset(int offset) async { + _invalidateCaches(); + await _analyticsClientGetter.database.updateXPOffset(offset); + } + + Future> updateLocalAnalytics( + AnalyticsUpdate update, + ) async { + final events = []; + + final morphIds = update.addedConstructs + .where((c) => c.constructType == ConstructTypeEnum.morph) + .map((c) => c.identifier) + .toSet(); + + final prevData = await derivedData; + final prevMorphs = await getConstructUses(morphIds.toList()); + + _invalidateCaches(); + await _ensureInitialized(); + await _analyticsClientGetter.database.updateLocalAnalytics( + update.addedConstructs, + ); + + final blocked = blockedConstructs; + _mergeTable.addConstructsByUses(update.addedConstructs, blocked); + + final newData = await derivedData; + + // Update public profile each time that new analytics are added. + // If the level hasn't changed, this will not send an update to the server. + // Do this on all updates (not just on level updates) to account for cases + // of target language updates being missed (https://github.com/pangeachat/client/issues/2006) + MatrixState.pangeaController.userController.updateAnalyticsProfile( + level: newData.level, + ); + + if (newData.level > prevData.level) { + events.add(LevelUpEvent(prevData.level, newData.level)); + } else if (newData.level < prevData.level) { + final lowerLevelXP = DerivedAnalyticsDataModel.calculateXpWithLevel( + prevData.level, + ); + + final offset = lowerLevelXP - newData.totalXP; + await MatrixState.pangeaController.userController.addXPOffset(offset); + await updateXPOffset( + MatrixState.pangeaController.userController.analyticsProfile!.xpOffset!, + ); + } + + final newMorphs = await getConstructUses(morphIds.toList()); + final newUnlockedMorphs = morphIds.where((id) { + final prevPoints = prevMorphs[id]?.points ?? 0; + final newPoints = newMorphs[id]?.points ?? 0; + return prevPoints < _morphUnlockXP && newPoints >= _morphUnlockXP; + }).toSet(); + + if (newUnlockedMorphs.isNotEmpty) { + events.add(MorphUnlockedEvent(newUnlockedMorphs)); + } + + final points = update.addedConstructs.fold(0, (s, c) => s + c.xp); + events.add(XPGainedEvent(points, update.targetID)); + + if (update.blockedConstruct != null) { + events.add(ConstructBlockedEvent(update.blockedConstruct!)); + } + + return events; + } + + Future updateServerAnalytics( + List events, + ) async { + _invalidateCaches(); + final blocked = blockedConstructs; + for (final event in events) { + _mergeTable.addConstructsByUses( + event.content.uses, + blocked, + ); + } + await _analyticsClientGetter.database.updateServerAnalytics(events); + } + + Future updateBlockedConstructs( + ConstructIdentifier constructId, + ) async { + await _ensureInitialized(); + _mergeTable.removeConstruct(constructId); + + final construct = + await _analyticsClientGetter.database.getConstructUse([constructId]); + + final derived = await derivedData; + final newXP = derived.totalXP - construct.points; + final newLevel = DerivedAnalyticsDataModel.calculateLevelWithXp(newXP); + + await MatrixState.pangeaController.userController.updateAnalyticsProfile( + level: newLevel, + ); + + await _analyticsClientGetter.database.updateDerivedStats( + DerivedAnalyticsDataModel(totalXP: newXP), + ); + + _invalidateCaches(); + updateDispatcher.sendConstructAnalyticsUpdate( + AnalyticsUpdate( + [], + blockedConstruct: constructId, + ), + ); + } + + Future clearLocalAnalytics() async { + _invalidateCaches(); + await _ensureInitialized(); + await _analyticsClientGetter.database.clearLocalConstructData(); + } +} diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart new file mode 100644 index 000000000..3d16f9018 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -0,0 +1,698 @@ +// ignore_for_file: implementation_imports, depend_on_referenced_packages + +import 'dart:async'; +import 'dart:math'; + +import 'package:matrix/matrix.dart'; +import 'package:sqflite_common/sqflite.dart'; +import 'package:synchronized/synchronized.dart'; + +import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; + +import 'package:matrix/src/database/database_file_storage_stub.dart' + if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart'; +import 'package:matrix/src/database/indexeddb_box.dart' + if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart'; + +class AnalyticsDatabase with DatabaseFileStorage { + final String name; + + late BoxCollection _collection; + late Box _lastEventTimestampBox; + + late Box _serverConstructsBox; + late Box _localConstructsBox; + late Box _aggregatedServerVocabConstructsBox; + late Box _aggregatedLocalVocabConstructsBox; + late Box _aggregatedServerMorphConstructsBox; + late Box _aggregatedLocalMorphConstructsBox; + + late Box _derivedServerStatsBox; + late Box _derivedLocalStatsBox; + + static const String _serverConstructsBoxName = 'box_server_constructs'; + + static const String _localConstructsBoxName = 'box_local_constructs'; + + /// Key is Tuple of construct lemma, type, and category + static const String _aggregatedServerVocabConstructsBoxName = + 'box_aggregated_server_vocab_constructs'; + + static const String _aggregatedLocalVocabConstructsBoxName = + 'box_aggregated_local_vocab_constructs'; + + static const String _aggregatedServerMorphConstructsBoxName = + 'box_aggregated_server_morph_constructs'; + + static const String _aggregatedLocalMorphConstructsBoxName = + 'box_aggregated_local_morph_constructs'; + + static const String _derivedServerStatsBoxName = 'box_derived_server_stats'; + + static const String _derivedLocalStatsBoxName = 'box_derived_local_stats'; + + static const String _lastEventTimestampBoxName = 'box_last_event_timestamp'; + + Database? database; + + /// Custom IdbFactory used to create the indexedDB. On IO platforms it would + /// lead to an error to import "dart:indexed_db" so this is dynamically + /// typed. + final dynamic idbFactory; + + /// Custom SQFlite Database Factory used for high level operations on IO + /// like delete. Set it if you want to use sqlite FFI. + final DatabaseFactory? sqfliteFactory; + + static Future init( + String name, { + Database? database, + dynamic idbFactory, + DatabaseFactory? sqfliteFactory, + Uri? fileStorageLocation, + Duration? deleteFilesAfterDuration, + }) async { + final analyticsDatabase = AnalyticsDatabase._( + name, + database: database, + idbFactory: idbFactory, + sqfliteFactory: sqfliteFactory, + fileStorageLocation: fileStorageLocation, + deleteFilesAfterDuration: deleteFilesAfterDuration, + ); + await analyticsDatabase.open(); + return analyticsDatabase; + } + + AnalyticsDatabase._( + this.name, { + this.database, + this.idbFactory, + this.sqfliteFactory, + Uri? fileStorageLocation, + Duration? deleteFilesAfterDuration, + }) { + this.fileStorageLocation = fileStorageLocation; + this.deleteFilesAfterDuration = deleteFilesAfterDuration; + } + + final _lock = Lock(); + + Future open() async { + _collection = await BoxCollection.open( + name, + { + _lastEventTimestampBoxName, + _serverConstructsBoxName, + _localConstructsBoxName, + _aggregatedServerVocabConstructsBoxName, + _aggregatedLocalVocabConstructsBoxName, + _aggregatedServerMorphConstructsBoxName, + _aggregatedLocalMorphConstructsBoxName, + _derivedServerStatsBoxName, + _derivedLocalStatsBoxName, + }, + sqfliteDatabase: database, + sqfliteFactory: sqfliteFactory, + idbFactory: idbFactory, + version: MatrixSdkDatabase.version, + ); + + _lastEventTimestampBox = _collection.openBox( + _lastEventTimestampBoxName, + ); + _serverConstructsBox = _collection.openBox( + _serverConstructsBoxName, + ); + _localConstructsBox = _collection.openBox( + _localConstructsBoxName, + ); + _aggregatedServerVocabConstructsBox = _collection.openBox( + _aggregatedServerVocabConstructsBoxName, + ); + _aggregatedLocalVocabConstructsBox = _collection.openBox( + _aggregatedLocalVocabConstructsBoxName, + ); + _aggregatedServerMorphConstructsBox = _collection.openBox( + _aggregatedServerMorphConstructsBoxName, + ); + _aggregatedLocalMorphConstructsBox = _collection.openBox( + _aggregatedLocalMorphConstructsBoxName, + ); + _derivedServerStatsBox = _collection.openBox( + _derivedServerStatsBoxName, + ); + _derivedLocalStatsBox = _collection.openBox( + _derivedLocalStatsBoxName, + ); + } + + Future delete() async { + await _collection.deleteDatabase( + database?.path ?? name, + sqfliteFactory ?? idbFactory, + ); + } + + Future clear() async { + _lastEventTimestampBox.clearQuickAccessCache(); + _serverConstructsBox.clearQuickAccessCache(); + _localConstructsBox.clearQuickAccessCache(); + _aggregatedServerVocabConstructsBox.clearQuickAccessCache(); + _aggregatedLocalVocabConstructsBox.clearQuickAccessCache(); + _aggregatedServerMorphConstructsBox.clearQuickAccessCache(); + _aggregatedLocalMorphConstructsBox.clearQuickAccessCache(); + _derivedServerStatsBox.clearQuickAccessCache(); + _derivedLocalStatsBox.clearQuickAccessCache(); + await _collection.clear(); + } + + Future _transaction(Future Function() action) { + return _lock.synchronized(action); + } + + Box _aggBox(ConstructTypeEnum type, bool local) => + switch ((type, local)) { + (ConstructTypeEnum.vocab, true) => _aggregatedLocalVocabConstructsBox, + (ConstructTypeEnum.vocab, false) => _aggregatedServerVocabConstructsBox, + (ConstructTypeEnum.morph, true) => _aggregatedLocalMorphConstructsBox, + (ConstructTypeEnum.morph, false) => _aggregatedServerMorphConstructsBox, + }; + + Future getLastEventTimestamp() async { + final timestampString = + await _lastEventTimestampBox.get('last_event_timestamp'); + if (timestampString == null) return null; + return DateTime.parse(timestampString); + } + + Future _getDerivedServerStats() async { + final raw = await _derivedServerStatsBox.get('derived_stats'); + return raw == null + ? DerivedAnalyticsDataModel() + : DerivedAnalyticsDataModel.fromJson( + Map.from(raw), + ); + } + + Future _getDerivedLocalStats() async { + final raw = await _derivedLocalStatsBox.get('derived_stats'); + return raw == null + ? DerivedAnalyticsDataModel() + : DerivedAnalyticsDataModel.fromJson( + Map.from(raw), + ); + } + + Future getDerivedStats() async { + DerivedAnalyticsDataModel server = DerivedAnalyticsDataModel(); + DerivedAnalyticsDataModel local = DerivedAnalyticsDataModel(); + server = await _getDerivedServerStats(); + local = await _getDerivedLocalStats(); + return server.merge(local); + } + + Future> getUses({ + int? count, + String? roomId, + DateTime? since, + }) async { + final stopwatch = Stopwatch()..start(); + final List uses = []; + + // first, get all of the local (most recent) keys + final localKeys = await _localConstructsBox.getAllKeys(); + final localValues = await _localConstructsBox.getAll(localKeys); + final local = Map.fromIterables( + localKeys, + localValues, + ).entries.toList(); + + local.sort( + (a, b) => int.parse(b.key).compareTo(int.parse(a.key)), + ); + + for (final entry in local) { + // filter by date + if (since != null && + int.parse(entry.key) < since.millisecondsSinceEpoch) { + continue; + } + + final rawUses = entry.value; + if (rawUses == null) continue; + for (final raw in rawUses) { + // filter by count + if (count != null && uses.length >= count) break; + + final use = OneConstructUse.fromJson( + Map.from(raw), + ); + + // filter by roomID + if (roomId != null && use.metadata.roomId != roomId) { + continue; + } + + uses.add(use); + } + if (count != null && uses.length >= count) break; + } + if (count != null && uses.length >= count) return uses; + + // then get server uses + final serverKeys = await _serverConstructsBox.getAllKeys(); + serverKeys.sort( + (a, b) => + int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])), + ); + for (final key in serverKeys) { + // filter by count + if (count != null && uses.length >= count) break; + final rawUses = await _serverConstructsBox.get(key); + if (rawUses == null) continue; + for (final raw in rawUses) { + if (count != null && uses.length >= count) break; + final use = OneConstructUse.fromJson( + Map.from(raw), + ); + + // filter by roomID + if (roomId != null && use.metadata.roomId != roomId) { + continue; + } + + // filter by date + if (since != null && use.timeStamp.isBefore(since)) { + continue; + } + uses.add(use); + } + } + + stopwatch.stop(); + Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms"); + + return uses.take(count ?? uses.length).toList(); + } + + Future> getLocalUses() async { + final List uses = []; + final localKeys = await _localConstructsBox.getAllKeys(); + final localValues = await _localConstructsBox.getAll(localKeys); + for (final rawList in localValues) { + if (rawList == null) continue; + for (final raw in rawList) { + final use = OneConstructUse.fromJson( + Map.from(raw), + ); + uses.add(use); + } + } + return uses; + } + + Future getLocalConstructCount() async { + final keys = await _localConstructsBox.getAllKeys(); + return keys.length; + } + + Future> getVocabConstructKeys() async { + final serverKeys = await _aggregatedServerVocabConstructsBox.getAllKeys(); + final localKeys = await _aggregatedLocalVocabConstructsBox.getAllKeys(); + return [...serverKeys, ...localKeys]; + } + + Future> getMorphConstructKeys() async { + final serverKeys = await _aggregatedServerMorphConstructsBox.getAllKeys(); + final localKeys = await _aggregatedLocalMorphConstructsBox.getAllKeys(); + return [...serverKeys, ...localKeys]; + } + + Future getConstructUse( + List ids, + ) async { + assert(ids.isNotEmpty); + + final ConstructUses construct = ConstructUses( + uses: [], + constructType: ids.first.type, + lemma: ids.first.lemma, + category: ids.first.category, + ); + + for (final id in ids) { + final key = id.storageKey; + + ConstructUses? server; + ConstructUses? local; + + final serverBox = _aggBox(id.type, false); + final localBox = _aggBox(id.type, true); + + final serverRaw = await serverBox.get(key); + if (serverRaw != null) { + server = ConstructUses.fromJson( + Map.from(serverRaw), + ); + } + + final localRaw = await localBox.get(key); + if (localRaw != null) { + local = ConstructUses.fromJson( + Map.from(localRaw), + ); + } + + if (server != null) construct.merge(server); + if (local != null) construct.merge(local); + } + return construct; + } + + Future> getConstructUses( + Map> ids, + ) async { + final Map results = {}; + for (final entry in ids.entries) { + final construct = await getConstructUse(entry.value); + results[entry.key] = construct; + } + return results; + } + + Future clearLocalConstructData() async { + await _transaction(() async { + await _localConstructsBox.clear(); + await _aggregatedLocalVocabConstructsBox.clear(); + await _aggregatedLocalMorphConstructsBox.clear(); + await _derivedLocalStatsBox.clear(); + }); + } + + /// Group uses by aggregate key + Map> _groupUses( + List uses, + ) { + final Map> grouped = {}; + for (final u in uses) { + final key = u.identifier.storageKey; + (grouped[key] ??= []).add(u); + } + return grouped; + } + + Map _aggregateConstructs( + Map> groups, + Map?> existingRaw, + ) { + final Map updates = {}; + + for (final entry in groups.entries) { + final key = entry.key; + final usesForKey = entry.value; + final raw = existingRaw[key]; + + ConstructUses model; + + if (raw is Map) { + model = ConstructUses.fromJson(raw); + } else { + final u = usesForKey.first; + model = ConstructUses( + uses: [], + constructType: u.constructType, + lemma: u.lemma, + category: u.category, + ); + } + + for (final u in usesForKey) { + model.uses.add(u); + model.setLastUsed(u.timeStamp); + } + + updates[key] = model; + } + + return updates; + } + + Future> _aggregateFromBox( + Box box, + Map> grouped, + ) async { + final keys = grouped.keys.toList(); + final existing = await box.getAll(keys); + + final existingMap = Map.fromIterables(keys, existing); + return _aggregateConstructs(grouped, existingMap); + } + + Future> getAggregatedConstructs( + ConstructTypeEnum type, + ) async { + Map combined = {}; + final stopwatch = Stopwatch()..start(); + + final localKeys = await _aggBox(type, true).getAllKeys(); + final serverKeys = await _aggBox(type, false).getAllKeys(); + + final serverValues = await _aggBox(type, false).getAll(serverKeys); + final serverConstructs = serverValues + .map((e) => ConstructUses.fromJson(Map.from(e!))) + .toList(); + + final serverAgg = Map.fromIterables( + serverKeys, + serverConstructs, + ); + + if (localKeys.isEmpty) { + combined = serverAgg; + } else { + final localValues = await _aggBox(type, true).getAll(localKeys); + final localConstructs = localValues + .map((e) => ConstructUses.fromJson(Map.from(e!))) + .toList(); + + final localAgg = Map.fromIterables( + localKeys, + localConstructs, + ); + + combined = Map.from(serverAgg); + for (final entry in localAgg.entries) { + final key = entry.key; + final localModel = entry.value; + + if (combined.containsKey(key)) { + final serverModel = combined[key]!; + serverModel.merge(localModel); + combined[key] = serverModel; + } else { + combined[key] = localModel; + } + } + } + + stopwatch.stop(); + Logs().i( + "Combining aggregates took ${stopwatch.elapsedMilliseconds} ms", + ); + + return combined.values.toList(); + } + + Future updateXPOffset(int offset) { + return _transaction(() async { + final serverStats = await _getDerivedServerStats(); + final localStats = await _getDerivedLocalStats(); + + final updatedServerStats = serverStats.copyWith( + offset: offset, + ); + final updatedLocalStats = localStats.copyWith( + offset: offset, + ); + + await _derivedServerStatsBox.put( + 'derived_stats', + updatedServerStats.toJson(), + ); + await _derivedLocalStatsBox.put( + 'derived_stats', + updatedLocalStats.toJson(), + ); + }); + } + + Future updateDerivedStats(DerivedAnalyticsDataModel newStats) => + _derivedServerStatsBox.put( + 'derived_stats', + newStats.toJson(), + ); + + Future updateServerAnalytics( + List events, + ) async { + if (events.isEmpty) return; + + final stopwatch = Stopwatch()..start(); + await _transaction(() async { + final lastUpdated = await getLastEventTimestamp(); + final derivedData = await _getDerivedServerStats(); + + DateTime mostRecent = lastUpdated ?? events.first.event.originServerTs; + final existingKeys = (await _serverConstructsBox.getAllKeys()).toSet(); + + final List aggregatedVocabUses = []; + final List aggregatedMorphUses = []; + final Map> pendingWrites = {}; + + for (final event in events) { + final ts = event.event.originServerTs; + final key = TupleKey( + event.event.eventId, + ts.millisecondsSinceEpoch.toString(), + ).toString(); + + if (lastUpdated != null && ts.isBefore(lastUpdated)) continue; + if (existingKeys.contains(key)) continue; + + if (ts.isAfter(mostRecent)) mostRecent = ts; + + pendingWrites[key] = event.content.uses; + for (final u in event.content.uses) { + u.constructType == ConstructTypeEnum.vocab + ? aggregatedVocabUses.add(u) + : aggregatedMorphUses.add(u); + } + } + + if (pendingWrites.isEmpty) return; + + // Write events sequentially + for (final e in pendingWrites.entries) { + _serverConstructsBox.put( + e.key, + e.value.map((u) => u.toJson()).toList(), + ); + } + + // Update aggregates + final aggVocabUpdates = await _aggregateFromBox( + _aggregatedServerVocabConstructsBox, + _groupUses(aggregatedVocabUses), + ); + + for (final entry in aggVocabUpdates.entries) { + await _aggregatedServerVocabConstructsBox.put( + entry.key, + entry.value.toJson(), + ); + } + + final aggMorphUpdates = await _aggregateFromBox( + _aggregatedServerMorphConstructsBox, + _groupUses(aggregatedMorphUses), + ); + + for (final entry in aggMorphUpdates.entries) { + await _aggregatedServerMorphConstructsBox.put( + entry.key, + entry.value.toJson(), + ); + } + + // Update derived stats + final updatedDerivedStats = derivedData.update( + [ + ...aggregatedVocabUses, + ...aggregatedMorphUses, + ], + ); + + await _derivedServerStatsBox.put( + 'derived_stats', + updatedDerivedStats.toJson(), + ); + + // Update timestamp + await _lastEventTimestampBox.put( + 'last_event_timestamp', + mostRecent.toIso8601String(), + ); + }); + + stopwatch.stop(); + Logs().i( + "Server analytics update took ${stopwatch.elapsedMilliseconds} ms", + ); + } + + Future updateLocalAnalytics( + List uses, + ) async { + if (uses.isEmpty) return; + + final stopwatch = Stopwatch()..start(); + await _transaction(() async { + // Store local constructs + final key = DateTime.now().millisecondsSinceEpoch; + _localConstructsBox.put( + key.toString(), + uses.map((u) => u.toJson()).toList(), + ); + + final List vocabUses = []; + final List morphUses = []; + for (final u in uses) { + u.constructType == ConstructTypeEnum.vocab + ? vocabUses.add(u) + : morphUses.add(u); + } + + // Update aggregates + final aggVocabUpdates = await _aggregateFromBox( + _aggregatedLocalVocabConstructsBox, + _groupUses(vocabUses), + ); + + for (final entry in aggVocabUpdates.entries) { + await _aggregatedLocalVocabConstructsBox.put( + entry.key, + entry.value.toJson(), + ); + } + + final aggMorphUpdates = await _aggregateFromBox( + _aggregatedLocalMorphConstructsBox, + _groupUses(morphUses), + ); + + for (final entry in aggMorphUpdates.entries) { + await _aggregatedLocalMorphConstructsBox.put( + entry.key, + entry.value.toJson(), + ); + } + + // Update derived stats + final derivedData = await _getDerivedLocalStats(); + final updatedDerivedStats = derivedData.update(uses); + await _derivedLocalStatsBox.put( + 'derived_stats', + updatedDerivedStats.toJson(), + ); + }); + + stopwatch.stop(); + Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms"); + } +} diff --git a/lib/pangea/analytics_data/analytics_database_builder.dart b/lib/pangea/analytics_data/analytics_database_builder.dart new file mode 100644 index 000000000..7f698aec8 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_database_builder.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'package:matrix/matrix.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:universal_html/html.dart' as html; + +import 'package:fluffychat/pangea/analytics_data/analytics_database.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/sqlcipher_stub.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; + +Future analyticsDatabaseBuilder(String name) async { + AnalyticsDatabase? database; + try { + database = await _constructDatabase(name); + await database.open(); + return database; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {"clientID": name}, + m: "Failed to open analytics database. Opening fallback database.", + ); + + Logs().wtf('Unable to construct database!', e, s); + // Try to delete database so that it can created again on next init: + database?.delete().catchError((err, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {}, + m: "Failed to delete analytics database after failed construction.", + ); + }); + + // Delete database file: + if (database == null && !kIsWeb) { + final dbFile = File(await _getDatabasePath(name)); + if (await dbFile.exists()) await dbFile.delete(); + } + + rethrow; + } +} + +Future _constructDatabase(String name) async { + if (kIsWeb) { + html.window.navigator.storage?.persist(); + return await AnalyticsDatabase.init(name); + } + + Directory? fileStorageLocation; + try { + fileStorageLocation = await getTemporaryDirectory(); + } on MissingPlatformDirectoryException catch (_) { + Logs().w( + 'No temporary directory for file cache available on this platform.', + ); + } + + final path = await _getDatabasePath(name); + + // fix dlopen for old Android + await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions(); + // import the SQLite / SQLCipher shared objects / dynamic libraries + final factory = + createDatabaseFactoryFfi(ffiInit: SQfLiteEncryptionHelper.ffiInit); + + // required for [getDatabasesPath] + databaseFactory = factory; + final database = await factory.openDatabase( + path, + options: OpenDatabaseOptions(version: 1), + ); + + return await AnalyticsDatabase.init( + name, + database: database, + fileStorageLocation: fileStorageLocation?.uri, + deleteFilesAfterDuration: const Duration(days: 30), + ); +} + +Future _getDatabasePath(String name) async { + final databaseDirectory = PlatformInfos.isIOS || PlatformInfos.isMacOS + ? await getLibraryDirectory() + : await getApplicationSupportDirectory(); + + return join(databaseDirectory.path, '$name.sqlite'); +} diff --git a/lib/pangea/analytics_data/analytics_sync_controller.dart b/lib/pangea/analytics_data/analytics_sync_controller.dart new file mode 100644 index 000000000..d0aaaccee --- /dev/null +++ b/lib/pangea/analytics_data/analytics_sync_controller.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnalyticsSyncController { + final Client client; + final AnalyticsDataService dataService; + + StreamSubscription? _subscription; + StreamController> syncStream = + StreamController>.broadcast(); + + AnalyticsSyncController({ + required this.client, + required this.dataService, + }); + + void start() { + _subscription ??= client.onSync.stream.listen(_onSync); + } + + void dispose() { + _subscription?.cancel(); + _subscription = null; + syncStream.close(); + } + + Future _onSync(SyncUpdate update) async { + final analyticsRoom = _getAnalyticsRoom(); + if (analyticsRoom == null) return; + + final events = + update.rooms?.join?[analyticsRoom.id]?.timeline?.events?.where( + (e) => + e.type == PangeaEventTypes.construct && e.senderId == client.userID, + ); + + if (events == null || events.isEmpty) return; + + final constructEvents = events + .map( + (e) => ConstructAnalyticsEvent( + event: Event.fromMatrixEvent(e, analyticsRoom), + ), + ) + .where((e) => e.event.status == EventStatus.synced) + .toList(); + + if (constructEvents.isEmpty) return; + await dataService.updateServerAnalytics(constructEvents); + + syncStream.add( + List.from(constructEvents.map((e) => e.event.eventId)), + ); + } + + Future bulkUpdate() async { + final analyticsRoom = _getAnalyticsRoom(); + if (analyticsRoom == null) return; + + final lastUpdated = await dataService.getLastUpdatedAnalytics(); + + final events = await analyticsRoom.getAnalyticsEvents( + userId: client.userID!, + since: lastUpdated, + ); + + if (events == null || events.isEmpty) return; + + await dataService.updateServerAnalytics(events); + } + + Room? _getAnalyticsRoom() { + final l2 = MatrixState.pangeaController.userController.userL2; + if (l2 == null) return null; + return client.analyticsRoomLocal(l2); + } +} diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart new file mode 100644 index 000000000..84d697020 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; + +class LevelUpdate { + final int prevLevel; + final int newLevel; + + LevelUpdate({ + required this.prevLevel, + required this.newLevel, + }); +} + +class AnalyticsUpdate { + final List addedConstructs; + final ConstructIdentifier? blockedConstruct; + final String? targetID; + + AnalyticsUpdate( + this.addedConstructs, { + this.blockedConstruct, + this.targetID, + }); +} + +class AnalyticsUpdateDispatcher { + final AnalyticsDataService dataService; + + final StreamController constructUpdateStream = + StreamController.broadcast(); + + final StreamController activityAnalyticsStream = + StreamController.broadcast(); + + final StreamController> unlockedConstructsStream = + StreamController>.broadcast(); + + final StreamController levelUpdateStream = + StreamController.broadcast(); + + final StreamController> + _lemmaInfoUpdateStream = StreamController< + MapEntry>.broadcast(); + + AnalyticsUpdateDispatcher(this.dataService); + + void dispose() { + constructUpdateStream.close(); + activityAnalyticsStream.close(); + unlockedConstructsStream.close(); + levelUpdateStream.close(); + _lemmaInfoUpdateStream.close(); + } + + Stream lemmaUpdateStream( + ConstructIdentifier constructId, + ) => + _lemmaInfoUpdateStream.stream + .where((update) => update.key == constructId) + .map((update) => update.value); + + void sendActivityAnalyticsUpdate( + String activityAnalytics, + ) => + activityAnalyticsStream.add(activityAnalytics); + + void sendLemmaInfoUpdate( + ConstructIdentifier constructId, + UserSetLemmaInfo lemmaInfo, + ) => + _lemmaInfoUpdateStream.add(MapEntry(constructId, lemmaInfo)); + + Future sendConstructAnalyticsUpdate( + AnalyticsUpdate analyticsUpdate, + ) async { + final events = await dataService.updateLocalAnalytics(analyticsUpdate); + for (final event in events) { + _dispatch(event); + } + } + + void _dispatch(AnalyticsUpdateEvent event) { + switch (event) { + case final LevelUpEvent e: + _onLevelUp(e.from, e.to); + break; + case final MorphUnlockedEvent e: + _onUnlockMorphLemmas(e.unlocked); + break; + case final XPGainedEvent e: + _onXPGained(e.points, e.targetID); + break; + case final ConstructBlockedEvent e: + _onBlockedConstruct(e.blockedConstruct); + break; + } + } + + void _onLevelUp(final int lowerLevel, final int upperLevel) { + levelUpdateStream.add( + LevelUpdate( + prevLevel: lowerLevel, + newLevel: upperLevel, + ), + ); + } + + void _onUnlockMorphLemmas(Set unlocked) { + const excludedLemmas = {'not_proper'}; + + final filtered = { + for (final id in unlocked) + if (!excludedLemmas.contains(id.lemma.toLowerCase())) id, + }; + + if (filtered.isNotEmpty) { + unlockedConstructsStream.add(filtered); + } + } + + void _onXPGained(int points, String? targetID) { + final update = AnalyticsStreamUpdate( + points: points, + targetID: targetID, + ); + constructUpdateStream.add(update); + } + + void _onBlockedConstruct(ConstructIdentifier constructId) { + final update = AnalyticsStreamUpdate( + blockedConstruct: constructId, + ); + constructUpdateStream.add(update); + } +} diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart new file mode 100644 index 000000000..511de32a5 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -0,0 +1,25 @@ +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; + +sealed class AnalyticsUpdateEvent {} + +class LevelUpEvent extends AnalyticsUpdateEvent { + final int from; + final int to; + LevelUpEvent(this.from, this.to); +} + +class MorphUnlockedEvent extends AnalyticsUpdateEvent { + final Set unlocked; + MorphUnlockedEvent(this.unlocked); +} + +class XPGainedEvent extends AnalyticsUpdateEvent { + final int points; + final String? targetID; + XPGainedEvent(this.points, this.targetID); +} + +class ConstructBlockedEvent extends AnalyticsUpdateEvent { + final ConstructIdentifier blockedConstruct; + ConstructBlockedEvent(this.blockedConstruct); +} diff --git a/lib/pangea/analytics_data/analytics_update_service.dart b/lib/pangea/analytics_data/analytics_update_service.dart new file mode 100644 index 000000000..29c0cbaf9 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_update_service.dart @@ -0,0 +1,164 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart'; +import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/languages/language_model.dart'; +import 'package:fluffychat/pangea/user/user_controller.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnalyticsUpdateService { + static const int _maxMessagesCached = 10; + + final AnalyticsDataService dataService; + + AnalyticsUpdateService(this.dataService); + + Completer? _updateCompleter; + + LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; + + Future _getAnalyticsRoom() async { + final l2 = _l2; + if (l2 == null) return null; + + final analyticsRoom = await dataService.getAnalyticsRoom(l2); + return analyticsRoom; + } + + Future onUpdateLanguages(LanguageUpdate update) async { + await sendLocalAnalyticsToAnalyticsRoom( + l2Override: update.prevTargetLang, + ); + await dataService.reinitialize(); + + final data = await dataService.derivedData; + MatrixState.pangeaController.userController + .updateAnalyticsProfile(level: data.level); + } + + Future addAnalytics( + String? targetID, + List newConstructs, + ) async { + await dataService.updateDispatcher.sendConstructAnalyticsUpdate( + AnalyticsUpdate( + newConstructs, + targetID: targetID, + ), + ); + + final localConstructCount = await dataService.getLocalConstructCount(); + final lastUpdated = await dataService.getLastUpdatedAnalytics(); + final difference = DateTime.now().difference(lastUpdated ?? DateTime.now()); + + if (localConstructCount > _maxMessagesCached || difference.inMinutes > 10) { + sendLocalAnalyticsToAnalyticsRoom(); + } + } + + Future sendLocalAnalyticsToAnalyticsRoom({ + LanguageModel? l2Override, + }) async { + final inProgress = + _updateCompleter != null && !_updateCompleter!.isCompleted; + + if (inProgress) { + await _updateCompleter!.future; + return; + } + + _updateCompleter = Completer(); + try { + await _updateAnalytics(l2Override: l2Override); + await dataService.clearLocalAnalytics(); + } catch (err, s) { + ErrorHandler.logError( + e: err, + m: "Failed to update analytics", + s: s, + data: { + "l2Override": l2Override, + }, + ); + } finally { + _updateCompleter?.complete(); + _updateCompleter = null; + } + } + + Future _updateAnalytics({LanguageModel? l2Override}) async { + final localConstructs = await dataService.getLocalUses(); + if (localConstructs.isEmpty) return; + final analyticsRoom = await _getAnalyticsRoom(); + + // and send cached analytics data to the room + final future = dataService.waitForSync(); + await analyticsRoom?.sendConstructsEvent(localConstructs); + await future; + } + + Future sendActivityAnalytics(String roomId) async { + final analyticsRoom = await _getAnalyticsRoom(); + if (analyticsRoom == null) return; + + await analyticsRoom.addActivityRoomId(roomId); + dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId); + } + + Future blockConstruct(ConstructIdentifier constructId) async { + final analyticsRoom = await _getAnalyticsRoom(); + if (analyticsRoom == null) return; + + final current = analyticsRoom.analyticsSettings; + final blockedConstructs = current.blockedConstructs; + final updated = current.copyWith( + blockedConstructs: { + ...blockedConstructs, + constructId, + }, + ); + + await analyticsRoom.setAnalyticsSettings(updated); + await dataService.updateBlockedConstructs(constructId); + } + + Future setLemmaInfo( + ConstructIdentifier constructId, { + String? emoji, + String? meaning, + }) async { + final analyticsRoom = await _getAnalyticsRoom(); + if (analyticsRoom == null) return; + + final userLemmaInfo = analyticsRoom.getUserSetLemmaInfo(constructId); + final updated = userLemmaInfo.copyWith( + emojis: emoji == null ? null : [emoji], + meaning: meaning, + ); + if (userLemmaInfo == updated) return; + dataService.updateDispatcher.sendLemmaInfoUpdate(constructId, updated); + + try { + await analyticsRoom.setUserSetLemmaInfo(constructId, updated); + } catch (err, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: err, + data: userLemmaInfo.toJson(), + s: s, + ); + } + } +} diff --git a/lib/pangea/analytics_data/analytics_updater_mixin.dart b/lib/pangea/analytics_data/analytics_updater_mixin.dart new file mode 100644 index 000000000..fa90aab51 --- /dev/null +++ b/lib/pangea/analytics_data/analytics_updater_mixin.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +mixin AnalyticsUpdater on State { + StreamSubscription? _analyticsSubscription; + + @override + void initState() { + super.initState(); + final updater = Matrix.of(context).analyticsDataService.updateDispatcher; + _analyticsSubscription = + updater.constructUpdateStream.stream.listen(_onAnalyticsUpdate); + } + + @override + void dispose() { + _analyticsSubscription?.cancel(); + super.dispose(); + } + + Future addAnalytics( + List constructs, + String? targetId, + ) => + Matrix.of(context).analyticsDataService.updateService.addAnalytics( + targetId, + constructs, + ); + + void _onAnalyticsUpdate(AnalyticsStreamUpdate update) { + if (update.targetID != null) { + OverlayUtil.showPointsGained(update.targetID!, update.points, context); + } + } +} diff --git a/lib/pangea/analytics_data/construct_merge_table.dart b/lib/pangea/analytics_data/construct_merge_table.dart new file mode 100644 index 000000000..3e3c18239 --- /dev/null +++ b/lib/pangea/analytics_data/construct_merge_table.dart @@ -0,0 +1,140 @@ +import 'package:collection/collection.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; + +class ConstructMergeTable { + Map> lemmaTypeGroups = {}; + Map otherToSpecific = {}; + + void addConstructs( + List constructs, + Set exclude, + ) { + addConstructsByUses(constructs.expand((c) => c.uses).toList(), exclude); + } + + void addConstructsByUses( + List uses, + Set exclude, + ) { + for (final use in uses) { + final id = use.identifier; + if (exclude.contains(id)) continue; + final composite = id.compositeKey; + (lemmaTypeGroups[composite] ??= {}).add(id); + } + + for (final use in uses) { + if (exclude.contains(use.identifier)) continue; + final id = use.identifier; + final composite = id.compositeKey; + if (id.category == 'other' && !otherToSpecific.containsKey(id)) { + final specific = lemmaTypeGroups[composite]!.firstWhereOrNull( + (k) => k.category != 'other', + ); + if (specific != null) { + otherToSpecific[id] = specific; + } + } + } + } + + void removeConstruct(ConstructIdentifier id) { + final composite = id.compositeKey; + final group = lemmaTypeGroups[composite]; + if (group == null) return; + + group.remove(id); + if (group.isEmpty) { + lemmaTypeGroups.remove(composite); + } + + if (id.category != 'other') { + final otherId = ConstructIdentifier( + lemma: id.lemma, + type: id.type, + category: 'other', + ); + otherToSpecific.remove(otherId); + } else { + otherToSpecific.remove(id); + } + } + + ConstructIdentifier resolve(ConstructIdentifier key) => + otherToSpecific[key] ?? key; + + List groupedIds( + ConstructIdentifier id, + Set exclude, + ) { + final keys = []; + if (!exclude.contains(id)) { + keys.add(id); + } + + if (id.category == 'other') { + final specificKey = otherToSpecific[id]; + if (specificKey != null) { + keys.add(specificKey); + } + return keys; + } + + final group = lemmaTypeGroups[id.compositeKey]; + if (group == null) return keys; + + final otherEntry = group.firstWhereOrNull((k) => k.category == 'other'); + if (otherEntry == null) return keys; + + final otherSpecificEntry = otherToSpecific[otherEntry]; + if (otherSpecificEntry == id) { + keys.add( + ConstructIdentifier( + lemma: id.lemma, + type: id.type, + category: 'other', + ), + ); + } + return keys; + } + + int uniqueConstructsByType(ConstructTypeEnum type) { + final keys = lemmaTypeGroups.keys.where( + (composite) => composite.endsWith('|${type.name}'), + ); + + int count = 0; + for (final composite in keys) { + final group = lemmaTypeGroups[composite]!; + if (group.any((e) => e.category == 'other')) { + // if this is the only entry in the group, it's a unique construct + if (group.length == 1) { + count += 1; + continue; + } + // otherwise, count all but the 'other' entry, + // which is merged into a more specific construct + count += group.length - 1; + continue; + } + + // all specific constructs, count them all + count += group.length; + } + + return count; + } + + bool constructUsed(ConstructIdentifier id) => + lemmaTypeGroups[id.compositeKey]?.contains(id) ?? false; + + void clear() { + lemmaTypeGroups.clear(); + otherToSpecific.clear(); + } +} diff --git a/lib/pangea/analytics_data/derived_analytics_data_model.dart b/lib/pangea/analytics_data/derived_analytics_data_model.dart new file mode 100644 index 000000000..04a0ccf92 --- /dev/null +++ b/lib/pangea/analytics_data/derived_analytics_data_model.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; + +class DerivedAnalyticsDataModel { + final int _totalXP; + final int offset; + + DerivedAnalyticsDataModel({ + int totalXP = 0, + this.offset = 0, + }) : _totalXP = totalXP; + + int get totalXP => _totalXP + offset; + + int get level => calculateLevelWithXp(_totalXP); + + // the minimum XP required for a given level + int get _minXPForLevel => calculateXpWithLevel(level); + + // the minimum XP required for the next level + int get minXPForNextLevel => calculateXpWithLevel(level + 1); + + // the progress within the current level as a percentage (0.0 to 1.0) + double get levelProgress { + final progress = + (_totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel); + return progress >= 0 ? progress : 0; + } + + static final double D = Environment.isStagingEnvironment ? 500 : 1500; + + static int calculateXpWithLevel(int level) { + // If level <= 1, XP should be 0 or negative by this math. + // In practice, you might clamp it to 0: + if (level <= 1) { + return 0; + } + + // Convert level to double for the math + final double lc = level.toDouble(); + + // XP from the inverse formula: + final double xpDouble = (D / 8.0) * (2.0 * pow(lc - 1.0, 2.0) - 1.0); + + // Floor or clamp to ensure non-negative. + final int xp = xpDouble.floor(); + return (xp < 0) ? 0 : xp; + } + + static int calculateLevelWithXp(int totalXP) { + final doubleScore = (1 + sqrt((1 + (8.0 * totalXP / D)) / 2.0)); + if (!doubleScore.isNaN && doubleScore.isFinite) { + return doubleScore.floor(); + } else { + ErrorHandler.logError( + e: "Calculated level in Nan or Infinity", + data: { + "totalXP": totalXP, + "level": doubleScore, + }, + ); + return 1; + } + } + + DerivedAnalyticsDataModel update(List uses) { + int xp = _totalXP; + + for (final u in uses) { + xp += u.xp; + } + + return copyWith( + totalXP: xp, + ); + } + + DerivedAnalyticsDataModel merge(DerivedAnalyticsDataModel other) { + return DerivedAnalyticsDataModel( + totalXP: _totalXP + other.totalXP, + offset: offset, + ); + } + + DerivedAnalyticsDataModel copyWith({ + int? totalXP, + int? offset, + }) { + return DerivedAnalyticsDataModel( + totalXP: totalXP ?? this.totalXP, + offset: offset ?? this.offset, + ); + } + + factory DerivedAnalyticsDataModel.fromJson(Map map) { + return DerivedAnalyticsDataModel( + totalXP: map['total_xp'] ?? 0, + ); + } + + Map toJson() { + return { + 'total_xp': _totalXP, + }; + } +} diff --git a/lib/pangea/analytics_data/level_up_analytics_service.dart b/lib/pangea/analytics_data/level_up_analytics_service.dart new file mode 100644 index 000000000..2004638d7 --- /dev/null +++ b/lib/pangea/analytics_data/level_up_analytics_service.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LevelUpAnalyticsService { + final Client client; + final Future Function() ensureInitialized; + final AnalyticsDataService dataService; + + const LevelUpAnalyticsService({ + required this.client, + required this.ensureInitialized, + required this.dataService, + }); + + Future getLevelUpAnalytics( + int lowerLevel, + int upperLevel, + DateTime? lastLevelUpTimestamp, + ) async { + await ensureInitialized(); + + final uses = await dataService.getUses(since: lastLevelUpTimestamp); + final messages = await _buildMessageContext(uses); + + final userController = MatrixState.pangeaController.userController; + final request = ConstructSummaryRequest( + constructs: uses, + messages: messages, + userL1: userController.userL1!.langCodeShort, + userL2: userController.userL2!.langCodeShort, + lowerLevel: lowerLevel, + upperLevel: upperLevel, + ); + + final response = await ConstructRepo.generateConstructSummary(request); + final summary = response.summary; + + summary.levelVocabConstructs = + dataService.uniqueConstructsByType(ConstructTypeEnum.vocab); + summary.levelGrammarConstructs = + dataService.uniqueConstructsByType(ConstructTypeEnum.morph); + + return summary; + } + + Future>> _buildMessageContext( + List uses, + ) async { + final Map> useEventIds = {}; + + for (final use in uses) { + final roomId = use.metadata.roomId; + final eventId = use.metadata.eventId; + if (roomId == null || eventId == null) continue; + + useEventIds.putIfAbsent(roomId, () => {}).add(eventId); + } + + final List> messages = []; + + for (final entry in useEventIds.entries) { + final room = client.getRoomById(entry.key); + if (room == null) continue; + + final timeline = await room.getTimeline(); + + for (final eventId in entry.value) { + try { + final event = await room.getEventById(eventId); + if (event == null) continue; + + final pangeaEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: room.client.userID == event.senderId, + ); + + messages.add({ + 'sent': pangeaEvent.originalSent?.text ?? pangeaEvent.body, + 'written': pangeaEvent.originalWrittenContent, + }); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'roomId': entry.key, + 'eventId': eventId, + }, + ); + } + } + } + + return messages; + } +} diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index baf2285a3..74de3ef25 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -1,10 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_analytics_list_view.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_details_view.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_details_view.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_view.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; @@ -33,24 +37,61 @@ class ConstructAnalyticsViewState extends State { MorphFeaturesAndTags morphs = defaultMorphMapping; List features = defaultMorphMapping.displayFeatures; + List? vocab; + bool isSearching = false; ConstructLevelEnum? selectedConstructLevel; + StreamSubscription? _blockedConstructSub; @override void initState() { super.initState(); _setMorphs(); + _setVocab(); + searchController.addListener(() { if (mounted) setState(() {}); }); + + _blockedConstructSub = Matrix.of(context) + .analyticsDataService + .updateDispatcher + .constructUpdateStream + .stream + .listen(_onBlockConstruct); } @override void dispose() { searchController.dispose(); + _blockedConstructSub?.cancel(); super.dispose(); } + void _onBlockConstruct(AnalyticsStreamUpdate update) { + final blocked = update.blockedConstruct; + if (blocked == null) return; + vocab?.removeWhere((e) => e.id == blocked); + if (widget.view == ConstructTypeEnum.vocab && widget.construct == null) { + setState(() {}); + } + } + + Future _setVocab() async { + try { + final analyticsService = Matrix.of(context).analyticsDataService; + final data = await analyticsService + .getAggregatedConstructs(ConstructTypeEnum.vocab); + + vocab = data.values.toList(); + vocab!.sort( + (a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase()), + ); + } finally { + if (mounted) setState(() {}); + } + } + Future _setMorphs() async { try { final resp = await MorphsRepo.get(); diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index 52a20e4e9..85975cef3 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; @@ -104,6 +103,7 @@ class MorphFeatureBox extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); + final analyticsService = Matrix.of(context).analyticsDataService; return Container( padding: const EdgeInsets.all(16.0), decoration: BoxDecoration( @@ -142,40 +142,27 @@ class MorphFeatureBox extends StatelessWidget { alignment: WrapAlignment.center, spacing: 16.0, runSpacing: 16.0, - children: allTags - .map( - (morphTag) { - final id = ConstructIdentifier( - lemma: morphTag, - type: ConstructTypeEnum.morph, - category: morphFeature, - ); + children: allTags.map( + (morphTag) { + final id = ConstructIdentifier( + lemma: morphTag, + type: ConstructTypeEnum.morph, + category: morphFeature, + ); - final analytics = MatrixState.pangeaController - .getAnalytics.constructListModel - .getConstructUses(id) ?? - ConstructUses( - lemma: morphTag, - constructType: ConstructTypeEnum.morph, - category: morphFeature, - uses: [], - ); - - return MorphTagChip( - morphFeature: morphFeature, - morphTag: morphTag, - constructAnalytics: analytics, - onTap: () => context.go( - "/rooms/analytics/${id.type.string}/${Uri.encodeComponent(jsonEncode(id.toJson()))}", - ), - ); - }, - ) - .sortedBy( - (chip) => chip.constructAnalytics.points, - ) - .reversed - .toList(), + return FutureBuilder( + future: analyticsService.getConstructUse(id), + builder: (context, snapshot) => MorphTagChip( + morphFeature: morphFeature, + morphTag: morphTag, + constructAnalytics: snapshot.data, + onTap: () => context.go( + "/rooms/analytics/${id.type.string}/${Uri.encodeComponent(jsonEncode(id.toJson()))}", + ), + ), + ); + }, + ).toList(), ), ), ], @@ -189,7 +176,7 @@ class MorphFeatureBox extends StatelessWidget { class MorphTagChip extends StatelessWidget { final String morphFeature; final String morphTag; - final ConstructUses constructAnalytics; + final ConstructUses? constructAnalytics; final VoidCallback? onTap; const MorphTagChip({ @@ -206,8 +193,9 @@ class MorphTagChip extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final unlocked = constructAnalytics.points > 0 || - Matrix.of(context).client.userID == Environment.supportUserId; + final unlocked = + constructAnalytics != null && constructAnalytics!.points > 0 || + Matrix.of(context).client.userID == Environment.supportUserId; return Material( type: MaterialType.transparency, @@ -224,7 +212,8 @@ class MorphTagChip extends StatelessWidget { begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ - constructAnalytics.lemmaCategory.color(context), + constructAnalytics?.lemmaCategory.color(context) ?? + ConstructLevelEnum.seeds.color(context), Colors.transparent, ], ) diff --git a/lib/pangea/analytics_details_popup/morph_analytics_xp_tile.dart b/lib/pangea/analytics_details_popup/morph_analytics_xp_tile.dart deleted file mode 100644 index 49e2c7487..000000000 --- a/lib/pangea/analytics_details_popup/morph_analytics_xp_tile.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; - -class ConstructUsesXPTile extends StatelessWidget { - final ConstructUses constructUses; - - const ConstructUsesXPTile( - this.constructUses, { - super.key, - }); - - @override - Widget build(BuildContext context) { - final ProgressIndicatorEnum indicator = - constructUses.constructType == ConstructTypeEnum.morph - ? ProgressIndicatorEnum.morphsUsed - : ProgressIndicatorEnum.wordsUsed; - - return Tooltip( - message: - "${constructUses.points} / ${constructUses.constructType.maxXPPerLemma}", - child: ListTile( - onTap: () {}, - title: Text( - constructUses.constructType == ConstructTypeEnum.morph - ? getGrammarCopy( - category: constructUses.category, - lemma: constructUses.lemma, - context: context, - ) ?? - constructUses.lemma - : constructUses.lemma, - ), - subtitle: Row( - children: [ - Expanded( - child: LinearProgressIndicator( - value: constructUses.points / - constructUses.constructType.maxXPPerLemma, - minHeight: 20, - borderRadius: const BorderRadius.all( - Radius.circular(AppConfig.borderRadius), - ), - color: indicator.color(context), - ), - ), - const SizedBox(width: 12), - Text("${constructUses.points}xp"), - ], - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - ), - ), - ); - } -} diff --git a/lib/pangea/analytics_details_popup/morph_details_view.dart b/lib/pangea/analytics_details_popup/morph_details_view.dart index 48a8d766a..fa2914cb5 100644 --- a/lib/pangea/analytics_details_popup/morph_details_view.dart +++ b/lib/pangea/analytics_details_popup/morph_details_view.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; import 'package:fluffychat/pangea/morphs/morph_feature_display.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_tag_display.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class MorphDetailsView extends StatelessWidget { final ConstructIdentifier constructId; @@ -23,40 +24,49 @@ class MorphDetailsView extends StatelessWidget { @override Widget build(BuildContext context) { - final construct = constructId.constructUses; - final Color textColor = Theme.of(context).brightness != Brightness.light - ? construct.lemmaCategory.color(context) - : construct.lemmaCategory.darkColor(context); + return FutureBuilder( + future: + Matrix.of(context).analyticsDataService.getConstructUse(constructId), + builder: (context, snapshot) { + final construct = snapshot.data; + final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds; + final Color textColor = Theme.of(context).brightness != Brightness.light + ? level.color(context) + : level.darkColor(context); - return SingleChildScrollView( - child: Column( - spacing: 16.0, - children: [ - MorphTagDisplay( - morphFeature: _morphFeature, - morphTag: _morphTag, - textColor: textColor, + return SingleChildScrollView( + child: Column( + spacing: 16.0, + children: [ + MorphTagDisplay( + morphFeature: _morphFeature, + morphTag: _morphTag, + textColor: textColor, + ), + MorphFeatureDisplay(morphFeature: _morphFeature), + MorphMeaningWidget( + feature: _morphFeature, + tag: _morphTag, + style: Theme.of(context).textTheme.bodyLarge, + ), + const Divider(), + if (construct != null) ...[ + ConstructXpWidget( + icon: construct.lemmaCategory.icon(30.0), + level: construct.lemmaCategory, + points: construct.points, + ), + Padding( + padding: const EdgeInsets.all(20.0), + child: AnalyticsDetailsUsageContent( + construct: construct, + ), + ), + ], + ], ), - MorphFeatureDisplay(morphFeature: _morphFeature), - MorphMeaningWidget( - feature: _morphFeature, - tag: _morphTag, - style: Theme.of(context).textTheme.bodyLarge, - ), - const Divider(), - ConstructXpWidget( - icon: construct.lemmaCategory.icon(30.0), - level: construct.lemmaCategory, - points: construct.points, - ), - Padding( - padding: const EdgeInsets.all(20.0), - child: AnalyticsDetailsUsageContent( - construct: construct, - ), - ), - ], - ), + ); + }, ); } } diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 88ecd5be8..d20a9ac6f 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -13,7 +13,7 @@ import 'package:fluffychat/pangea/toolbar/word_card/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Displays information about selected lemma, and its usage -class VocabDetailsView extends StatefulWidget { +class VocabDetailsView extends StatelessWidget { final ConstructIdentifier constructId; const VocabDetailsView({ @@ -21,100 +21,89 @@ class VocabDetailsView extends StatefulWidget { required this.constructId, }); - @override - State createState() => VocabDetailsViewState(); -} - -class VocabDetailsViewState extends State { - ConstructIdentifier get constructId => widget.constructId; - - final ValueNotifier _emojiNotifier = ValueNotifier(null); - - @override - void initState() { - super.initState(); - _emojiNotifier.value = constructId.userLemmaInfo.emojis?.firstOrNull; - } - - @override - void didUpdateWidget(covariant VocabDetailsView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.constructId != widget.constructId) { - _emojiNotifier.value = constructId.userLemmaInfo.emojis?.firstOrNull; - } - } - - @override - void dispose() { - _emojiNotifier.dispose(); - super.dispose(); - } - - List get forms => - MatrixState.pangeaController.getAnalytics.constructListModel - .getConstructUsesByLemma(constructId.lemma) - .map((e) => e.uses) - .expand((element) => element) - .map((e) => e.form?.toLowerCase()) - .toSet() - .whereType() - .toList(); - @override Widget build(BuildContext context) { - final construct = constructId.constructUses; - final Color textColor = (Theme.of(context).brightness != Brightness.light - ? construct.lemmaCategory.color(context) - : construct.lemmaCategory.darkColor(context)); + final analyticsService = Matrix.of(context).analyticsDataService; + return FutureBuilder( + future: analyticsService.getConstructUse(constructId), + builder: (context, snapshot) { + final construct = snapshot.data; + final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds; - return SingleChildScrollView( - child: Column( - spacing: 16.0, - children: [ - WordZoomWidget( - token: PangeaTokenText.fromString(constructId.lemma), - langCode: MatrixState.pangeaController.userController.userL2Code!, - construct: constructId, - setEmoji: (emoji) => _emojiNotifier.value = emoji, - ), - Column( + final Color textColor = + (Theme.of(context).brightness != Brightness.light + ? level.color(context) + : level.darkColor(context)); + + final forms = construct?.uses + .map((e) => e.form) + .whereType() + .toSet() + .toList() ?? + []; + + return SingleChildScrollView( + child: Column( + spacing: 16.0, children: [ - Padding( - padding: const EdgeInsets.all(20.0), - child: ConstructXpWidget( - icon: ValueListenableBuilder( - valueListenable: _emojiNotifier, - builder: (context, emoji, __) => Text( - emoji ?? "-", - style: const TextStyle(fontSize: 24.0), - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + WordZoomWidget( + token: PangeaTokenText.fromString(constructId.lemma), + langCode: + MatrixState.pangeaController.userController.userL2Code!, + construct: constructId, ), - level: construct.lemmaCategory, - points: construct.points, - ), + ], ), - Padding( - padding: const EdgeInsets.all(20.0), - child: Column( + if (construct != null) + Column( children: [ - Align( - alignment: Alignment.centerLeft, - child: _VocabForms( - lemma: constructId.lemma, - forms: forms, - textColor: textColor, + Padding( + padding: const EdgeInsets.all(20.0), + child: ConstructXpWidget( + icon: StreamBuilder( + key: ValueKey(constructId.string), + stream: analyticsService.updateDispatcher + .lemmaUpdateStream(constructId), + builder: (context, update) { + final emoji = update.data?.emojis?.firstOrNull ?? + constructId.userSetEmoji; + return Text( + emoji ?? "-", + style: const TextStyle(fontSize: 24.0), + ); + }, + ), + level: construct.lemmaCategory, + points: construct.points, ), ), - AnalyticsDetailsUsageContent( - construct: construct, + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: _VocabForms( + lemma: constructId.lemma, + forms: forms, + textColor: textColor, + ), + ), + AnalyticsDetailsUsageContent( + construct: construct, + ), + ], + ), ), ], ), - ), ], ), - ], - ), + ); + }, ); } } diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart index e14fab142..3d7ede256 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart @@ -3,22 +3,22 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/common/widgets/shrinkable_text.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class VocabAnalyticsListTile extends StatefulWidget { const VocabAnalyticsListTile({ super.key, - required this.emoji, required this.constructId, + this.level = ConstructLevelEnum.seeds, required this.textColor, - required this.icon, this.onTap, }); - final String? emoji; final void Function()? onTap; final ConstructIdentifier constructId; + final ConstructLevelEnum level; final Color textColor; - final Widget icon; @override VocabAnalyticsListTileState createState() => VocabAnalyticsListTileState(); @@ -32,6 +32,7 @@ class VocabAnalyticsListTileState extends State { @override Widget build(BuildContext context) { + final analyticsService = Matrix.of(context).analyticsDataService; return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), @@ -53,10 +54,26 @@ class VocabAnalyticsListTileState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - alignment: Alignment.center, - height: (maxWidth - padding * 2) * 0.6, - child: widget.icon, + StreamBuilder( + stream: analyticsService.updateDispatcher + .lemmaUpdateStream(widget.constructId), + builder: (context, snapshot) { + final emoji = snapshot.data?.emojis?.firstOrNull ?? + widget.constructId.userSetEmoji; + + return Container( + alignment: Alignment.center, + height: (maxWidth - padding * 2) * 0.6, + child: emoji != null + ? Text( + emoji, + style: const TextStyle( + fontSize: 22, + ), + ) + : widget.level.icon(36.0), + ); + }, ), Container( alignment: Alignment.topCenter, diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart index bf3f18b0f..c525c2ef2 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_view.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.dart'; @@ -29,13 +28,8 @@ class VocabAnalyticsListView extends StatelessWidget { required this.controller, }); - List get _vocab => MatrixState - .pangeaController.getAnalytics.constructListModel - .constructList(type: ConstructTypeEnum.vocab) - .sorted((a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase())); - - List get _filteredVocab => _vocab - .where( + List? get _filteredVocab => controller.vocab + ?.where( (use) => use.lemma.isNotEmpty && (controller.selectedConstructLevel == null @@ -51,11 +45,14 @@ class VocabAnalyticsListView extends StatelessWidget { @override Widget build(BuildContext context) { + final vocab = controller.vocab; final List filters = ConstructLevelEnum.values.reversed .map((constructLevelCategory) { - final int count = _vocab - .where((e) => e.lemmaCategory == constructLevelCategory) - .length; + final int count = vocab + ?.where((e) => e.lemmaCategory == constructLevelCategory) + .length ?? + 0; + return InkWell( onTap: () => controller.setSelectedConstructLevel(constructLevelCategory), @@ -152,60 +149,60 @@ class VocabAnalyticsListView extends StatelessWidget { ), // Grid of vocab tiles - _filteredVocab.isEmpty - ? SliverToBoxAdapter( - child: controller.selectedConstructLevel != null - ? Padding( - padding: const EdgeInsets.all(24.0), - child: Text( - L10n.of(context).vocabLevelsDesc, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ) - : const SizedBox.shrink(), - ) - : SliverGrid( - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 100.0, - mainAxisExtent: 100.0, - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, + if (vocab == null) + const SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: CircularProgressIndicator.adaptive(), + ), + ) + else + vocab.isEmpty + ? SliverToBoxAdapter( + child: controller.selectedConstructLevel != null + ? Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + L10n.of(context).vocabLevelsDesc, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ) + : const SizedBox.shrink(), + ) + : SliverGrid( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 100.0, + mainAxisExtent: 100.0, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + final vocabItem = _filteredVocab![index]; + return VocabAnalyticsListTile( + onTap: () { + TtsController.tryToSpeak( + vocabItem.id.lemma, + langCode: MatrixState.pangeaController + .userController.userL2Code!, + ); + context.go( + "/rooms/analytics/${vocabItem.id.type.string}/${Uri.encodeComponent(jsonEncode(vocabItem.id.toJson()))}", + ); + }, + constructId: vocabItem.id, + textColor: Theme.of(context).brightness == + Brightness.light + ? vocabItem.lemmaCategory.darkColor(context) + : vocabItem.lemmaCategory.color(context), + level: vocabItem.lemmaCategory, + ); + }, + childCount: _filteredVocab!.length, + ), ), - delegate: SliverChildBuilderDelegate( - (context, index) { - final vocabItem = _filteredVocab[index]; - return VocabAnalyticsListTile( - onTap: () { - TtsController.tryToSpeak( - vocabItem.id.lemma, - langCode: MatrixState.pangeaController - .userController.userL2Code!, - ); - context.go( - "/rooms/analytics/${vocabItem.id.type.string}/${Uri.encodeComponent(jsonEncode(vocabItem.id.toJson()))}", - ); - }, - constructId: vocabItem.id, - textColor: - Theme.of(context).brightness == Brightness.light - ? vocabItem.lemmaCategory.darkColor(context) - : vocabItem.lemmaCategory.color(context), - emoji: vocabItem.id.userSetEmoji.firstOrNull, - icon: vocabItem.id.userSetEmoji.isNotEmpty - ? Text( - vocabItem.id.userSetEmoji.first, - style: const TextStyle( - fontSize: 22, - ), - ) - : vocabItem.lemmaCategory.icon(36.0), - ); - }, - childCount: _filteredVocab.length, - ), - ), ], ), ), diff --git a/lib/pangea/analytics_details_popup/vocab_details_emoji_selector.dart b/lib/pangea/analytics_details_popup/vocab_details_emoji_selector.dart deleted file mode 100644 index e55016aed..000000000 --- a/lib/pangea/analytics_details_popup/vocab_details_emoji_selector.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class VocabDetailsEmojiSelector extends StatefulWidget { - final ConstructIdentifier constructId; - - const VocabDetailsEmojiSelector( - this.constructId, { - super.key, - }); - - @override - State createState() => - VocabDetailsEmojiSelectorState(); -} - -class VocabDetailsEmojiSelectorState extends State - with LemmaEmojiSetter { - String? selectedEmoji; - - @override - void initState() { - super.initState(); - _setInitialEmoji(); - } - - @override - void didUpdateWidget(covariant VocabDetailsEmojiSelector oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.constructId != widget.constructId) { - _setInitialEmoji(); - } - } - - void _setInitialEmoji() { - setState( - () { - selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull; - }, - ); - } - - Future _setEmoji(String emoji) async { - setState(() => selectedEmoji = emoji); - await setLemmaEmoji( - widget.constructId, - emoji, - "emoji-choice-item-$emoji-${widget.constructId.lemma}", - ); - showLemmaEmojiSnackbar(context, widget.constructId, emoji); - } - - @override - Widget build(BuildContext context) { - return LemmaHighlightEmojiRow( - cId: widget.constructId, - langCode: MatrixState.pangeaController.userController.userL2Code!, - emoji: selectedEmoji, - onEmojiSelected: _setEmoji, - messageInfo: const {}, - ); - } -} diff --git a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart index 8674e0060..cf5c2e618 100644 --- a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart +++ b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart @@ -147,8 +147,11 @@ class AnalyticsDownloadDialogState extends State { } Future> _getVocabAnalytics() async { - final uses = MatrixState.pangeaController.getAnalytics.constructListModel - .constructList(type: ConstructTypeEnum.vocab); + final analyticsService = Matrix.of(context).analyticsDataService; + final aggregatedVocab = + await analyticsService.getAggregatedConstructs(ConstructTypeEnum.vocab); + + final uses = aggregatedVocab.values.toList(); final Map> lemmasToUses = {}; for (final use in uses) { lemmasToUses[use.lemma] ??= []; @@ -194,8 +197,7 @@ class AnalyticsDownloadDialogState extends State { } Future> _getMorphAnalytics() async { - final constructListModel = - MatrixState.pangeaController.getAnalytics.constructListModel; + final analyticsService = Matrix.of(context).analyticsDataService; final morphs = await MorphsRepo.get(); final List summaries = []; @@ -212,8 +214,7 @@ class AnalyticsDownloadDialogState extends State { category: feature.feature, ); - final uses = constructListModel.getConstructUses(id); - if (uses == null) continue; + final uses = await analyticsService.getConstructUse(id); final xp = uses.points; final exampleMessages = await _getExampleMessages([uses]); diff --git a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart index 7017c1d3c..5c742bdee 100644 --- a/lib/pangea/analytics_downloads/space_analytics_summary_model.dart +++ b/lib/pangea/analytics_downloads/space_analytics_summary_model.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart'; +import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.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/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; class SpaceAnalyticsSummaryModel { String username; @@ -87,127 +91,223 @@ class SpaceAnalyticsSummaryModel { ); } - static SpaceAnalyticsSummaryModel fromConstructListModel( - String userID, - ConstructListModel? model, + static SpaceAnalyticsSummaryModel fromEvents( + String username, + List events, + Set blockedConstructs, int numCompletedActivities, - String Function(ConstructUses) getCopy, - BuildContext context, ) { - final vocabLemmas = model != null - ? LemmasToUsesWrapper( - model.lemmasToUses(type: ConstructTypeEnum.vocab), - ) - : null; - final morphLemmas = model != null - ? LemmasToUsesWrapper( - model.lemmasToUses(type: ConstructTypeEnum.morph), - ) - : null; + int totalXP = 0; + int numWordsTyped = 0; + int numChoicesCorrect = 0; + int numChoicesIncorrect = 0; - final List correctOriginalUseLemmas = []; - final List correctSystemUseLemmas = []; - final List incorrectOriginalUseLemmas = []; - final List incorrectSystemUseLemmas = []; + final Set sentEventIds = {}; + final List allUses = []; - if (morphLemmas != null) { - final originalWrittenUses = morphLemmas.lemmasByPercent( - filter: (use) => - use.useType == ConstructUseTypeEnum.wa || - use.useType == ConstructUseTypeEnum.ga || - use.useType == ConstructUseTypeEnum.ta, - percent: 0.8, - context: context, - ); + final Map> aggregatedVocab = {}; + final Map> aggregatedMorph = {}; - correctOriginalUseLemmas.addAll(originalWrittenUses.over); - incorrectOriginalUseLemmas.addAll(originalWrittenUses.under); + final ConstructMergeTable mergeTable = ConstructMergeTable(); - final systemGeneratedUses = morphLemmas.lemmasByPercent( - filter: (use) => - use.useType != ConstructUseTypeEnum.wa && - use.useType != ConstructUseTypeEnum.ga && - use.useType != ConstructUseTypeEnum.ta && - use.useType != ConstructUseTypeEnum.unk && - use.xp != 0, - percent: 0.8, - context: context, - ); + for (final e in events) { + mergeTable.addConstructsByUses(e.content.uses, blockedConstructs); - correctSystemUseLemmas.addAll(systemGeneratedUses.over); - incorrectSystemUseLemmas.addAll(systemGeneratedUses.under); - } + for (final use in e.content.uses) { + totalXP += use.xp; + allUses.add(use); - final vocabLemmasCorrect = vocabLemmas?.lemmasByCorrectUse(); - - int? numWordsTyped; - int? numChoicesCorrect; - int? numChoicesIncorrect; - if (model != null) { - numWordsTyped = 0; - numChoicesCorrect = 0; - numChoicesIncorrect = 0; - for (final use in model.uses) { if (use.useType.summaryEnumType == SpaceAnalyticsSummaryEnum.numWordsTyped) { - numWordsTyped = numWordsTyped! + 1; + numWordsTyped = numWordsTyped + 1; } else if (use.useType.summaryEnumType == SpaceAnalyticsSummaryEnum.numChoicesCorrect) { - numChoicesCorrect = numChoicesCorrect! + 1; + numChoicesCorrect = numChoicesCorrect + 1; } else if (use.useType.summaryEnumType == SpaceAnalyticsSummaryEnum.numChoicesIncorrect) { - numChoicesIncorrect = numChoicesIncorrect! + 1; + numChoicesIncorrect = numChoicesIncorrect + 1; + } + + if (use.useType.sentByUser && use.metadata.eventId != null) { + sentEventIds.add(use.metadata.eventId!); + } + + final id = use.identifier; + final existing = id.type == ConstructTypeEnum.vocab + ? aggregatedVocab[id] + : aggregatedMorph[id]; + + if (existing != null) { + existing.add(use); + } else { + id.type == ConstructTypeEnum.vocab + ? aggregatedVocab[id] = [use] + : aggregatedMorph[id] = [use]; } } } - final numMessageSent = model?.uses - .where((use) => use.useType.sentByUser) - .map((use) => use.metadata.eventId) - .toSet() - .length; + final Map aggregatedVocabUses = {}; + for (final entry in aggregatedVocab.entries) { + aggregatedVocabUses[entry.key] = ConstructUses( + lemma: entry.value.first.lemma, + constructType: entry.value.first.constructType, + category: entry.value.first.category, + uses: entry.value, + ); + } + + final Map aggregatedMorphUses = {}; + for (final entry in aggregatedMorph.entries) { + aggregatedMorphUses[entry.key] = ConstructUses( + lemma: entry.value.first.lemma, + constructType: entry.value.first.constructType, + category: entry.value.first.category, + uses: entry.value, + ); + } + + final cleanedVocab = {}; + for (final entry in aggregatedVocabUses.values) { + final canonical = mergeTable.resolve(entry.id); + final existing = cleanedVocab[canonical]; + if (existing != null) { + existing.merge(entry); + } else { + cleanedVocab[canonical] = entry; + } + } + + final cleanedMorph = {}; + for (final entry in aggregatedMorphUses.values) { + final canonical = mergeTable.resolve(entry.id); + final existing = cleanedMorph[canonical]; + if (existing != null) { + existing.merge(entry); + } else { + cleanedMorph[canonical] = entry; + } + } + + final level = DerivedAnalyticsDataModel.calculateLevelWithXp(totalXP); + final uniqueVocabCount = cleanedVocab.length; + final uniqueMorphCount = cleanedMorph.length; + + int vocabUsedCorrectly = 0; + int vocabUsedIncorrectly = 0; + int vocabSmallXP = 0; + int vocabMediumXP = 0; + int vocabLargeXP = 0; + + for (final entry in cleanedVocab.values) { + final xp = entry.points; + + if (xp >= 0 && xp <= 29) { + vocabSmallXP += 1; + } else if (xp >= 30 && xp < 200) { + vocabMediumXP += 1; + } else if (xp >= 200) { + vocabLargeXP += 1; + } + + if (entry.hasCorrectUse) { + vocabUsedCorrectly += 1; + } else { + vocabUsedIncorrectly += 1; + } + } + + final originalUseTypes = { + ConstructUseTypeEnum.wa, + ConstructUseTypeEnum.ga, + ConstructUseTypeEnum.ta, + }; + + final List morphConstructs = []; + final List morphSmallXP = []; + final List morphMediumXP = []; + final List morphLargeXP = []; + final List morphHugeXP = []; + final List morphCorrectOriginal = []; + final List morphIncorrectOriginal = []; + final List morphCorrectSystem = []; + final List morphIncorrectSystem = []; + + for (final entry in cleanedMorph.values) { + morphConstructs.add(entry.lemma); + final xp = entry.points; + + if (xp >= 0 && xp <= 50) { + morphSmallXP.add(entry.lemma); + } else if (xp >= 51 && xp <= 200) { + morphMediumXP.add(entry.lemma); + } else if (xp >= 201 && xp <= 500) { + morphLargeXP.add(entry.lemma); + } else if (xp >= 501) { + morphHugeXP.add(entry.lemma); + } + + final originalUsesCorrect = []; + final originalUsesIncorrect = []; + final systemUsesCorrect = []; + final systemUsesIncorrect = []; + + for (final use in entry.uses) { + if (originalUseTypes.contains(use.useType)) { + use.xp > 0 + ? originalUsesCorrect.add(use) + : originalUsesIncorrect.add(use); + } else { + use.xp > 0 + ? systemUsesCorrect.add(use) + : systemUsesIncorrect.add(use); + } + } + + // if >= 80% correct original uses + if (originalUsesCorrect.length + originalUsesIncorrect.length > 0) { + final percentCorrect = originalUsesCorrect.length / + (originalUsesCorrect.length + originalUsesIncorrect.length); + if (percentCorrect >= 0.8) { + morphCorrectOriginal.add(entry.lemma); + } else { + morphIncorrectOriginal.add(entry.lemma); + } + + if (systemUsesCorrect.length + systemUsesIncorrect.length > 0) { + final percentCorrectSystem = systemUsesCorrect.length / + (systemUsesCorrect.length + systemUsesIncorrect.length); + if (percentCorrectSystem >= 0.8) { + morphCorrectSystem.add(entry.lemma); + } else { + morphIncorrectSystem.add(entry.lemma); + } + } + } + } return SpaceAnalyticsSummaryModel( - username: userID, - dataAvailable: model != null, - level: model?.level, - totalXP: model?.totalXP, - numLemmas: model?.numConstructs(ConstructTypeEnum.vocab), - numLemmasUsedCorrectly: vocabLemmasCorrect?.over.length, - numLemmasUsedIncorrectly: vocabLemmasCorrect?.under.length, - numLemmasSmallXP: - vocabLemmas?.thresholdedLemmas(start: 0, end: 30).length, - numLemmasMediumXP: - vocabLemmas?.thresholdedLemmas(start: 31, end: 200).length, - numLemmasLargeXP: vocabLemmas?.thresholdedLemmas(start: 201).length, - numMorphConstructs: model?.numConstructs(ConstructTypeEnum.morph), - listMorphConstructs: morphLemmas?.lemmasToUses.entries - .map((entry) => getCopy(entry.value.first)) - .toList(), - listMorphConstructsUsedCorrectlyOriginal: correctOriginalUseLemmas, - listMorphConstructsUsedIncorrectlyOriginal: incorrectOriginalUseLemmas, - listMorphConstructsUsedCorrectlySystem: correctSystemUseLemmas, - listMorphConstructsUsedIncorrectlySystem: incorrectSystemUseLemmas, - listMorphSmallXP: morphLemmas?.thresholdedLemmas( - start: 0, - end: 50, - getCopy: getCopy, - ), - listMorphMediumXP: morphLemmas?.thresholdedLemmas( - start: 51, - end: 200, - getCopy: getCopy, - ), - listMorphLargeXP: morphLemmas?.thresholdedLemmas( - start: 201, - end: 500, - getCopy: getCopy, - ), - listMorphHugeXP: morphLemmas?.thresholdedLemmas( - start: 501, - getCopy: getCopy, - ), - numMessagesSent: numMessageSent, + username: username, + dataAvailable: true, + level: level, + totalXP: totalXP, + numLemmas: uniqueVocabCount, + numLemmasUsedCorrectly: vocabUsedCorrectly, + numLemmasUsedIncorrectly: vocabUsedIncorrectly, + numLemmasSmallXP: vocabSmallXP, + numLemmasMediumXP: vocabMediumXP, + numLemmasLargeXP: vocabLargeXP, + numMorphConstructs: uniqueMorphCount, + listMorphConstructs: morphConstructs, + listMorphSmallXP: morphSmallXP, + listMorphMediumXP: morphMediumXP, + listMorphLargeXP: morphLargeXP, + listMorphHugeXP: morphHugeXP, + listMorphConstructsUsedCorrectlyOriginal: morphCorrectOriginal, + listMorphConstructsUsedIncorrectlyOriginal: morphIncorrectOriginal, + listMorphConstructsUsedCorrectlySystem: morphCorrectSystem, + listMorphConstructsUsedIncorrectlySystem: morphIncorrectSystem, + numMessagesSent: sentEventIds.length, numWordsTyped: numWordsTyped, numChoicesCorrect: numChoicesCorrect, numChoicesIncorrect: numChoicesIncorrect, @@ -215,6 +315,134 @@ class SpaceAnalyticsSummaryModel { ); } + // static SpaceAnalyticsSummaryModel fromConstructListModel( + // String userID, + // ConstructListModel? model, + // int numCompletedActivities, + // String Function(ConstructUses) getCopy, + // BuildContext context, + // ) { + // final vocabLemmas = model != null + // ? LemmasToUsesWrapper( + // model.lemmasToUses(type: ConstructTypeEnum.vocab), + // ) + // : null; + // final morphLemmas = model != null + // ? LemmasToUsesWrapper( + // model.lemmasToUses(type: ConstructTypeEnum.morph), + // ) + // : null; + + // final List correctOriginalUseLemmas = []; + // final List correctSystemUseLemmas = []; + // final List incorrectOriginalUseLemmas = []; + // final List incorrectSystemUseLemmas = []; + + // if (morphLemmas != null) { + // final originalWrittenUses = morphLemmas.lemmasByPercent( + // filter: (use) => + // use.useType == ConstructUseTypeEnum.wa || + // use.useType == ConstructUseTypeEnum.ga || + // use.useType == ConstructUseTypeEnum.ta, + // percent: 0.8, + // context: context, + // ); + + // correctOriginalUseLemmas.addAll(originalWrittenUses.over); + // incorrectOriginalUseLemmas.addAll(originalWrittenUses.under); + + // final systemGeneratedUses = morphLemmas.lemmasByPercent( + // filter: (use) => + // use.useType != ConstructUseTypeEnum.wa && + // use.useType != ConstructUseTypeEnum.ga && + // use.useType != ConstructUseTypeEnum.ta && + // use.useType != ConstructUseTypeEnum.unk && + // use.xp != 0, + // percent: 0.8, + // context: context, + // ); + + // correctSystemUseLemmas.addAll(systemGeneratedUses.over); + // incorrectSystemUseLemmas.addAll(systemGeneratedUses.under); + // } + + // final vocabLemmasCorrect = vocabLemmas?.lemmasByCorrectUse(); + + // int? numWordsTyped; + // int? numChoicesCorrect; + // int? numChoicesIncorrect; + // if (model != null) { + // numWordsTyped = 0; + // numChoicesCorrect = 0; + // numChoicesIncorrect = 0; + // for (final use in model.uses) { + // if (use.useType.summaryEnumType == + // SpaceAnalyticsSummaryEnum.numWordsTyped) { + // numWordsTyped = numWordsTyped! + 1; + // } else if (use.useType.summaryEnumType == + // SpaceAnalyticsSummaryEnum.numChoicesCorrect) { + // numChoicesCorrect = numChoicesCorrect! + 1; + // } else if (use.useType.summaryEnumType == + // SpaceAnalyticsSummaryEnum.numChoicesIncorrect) { + // numChoicesIncorrect = numChoicesIncorrect! + 1; + // } + // } + // } + + // final numMessageSent = model?.uses + // .where((use) => use.useType.sentByUser) + // .map((use) => use.metadata.eventId) + // .toSet() + // .length; + + // return SpaceAnalyticsSummaryModel( + // username: userID, + // dataAvailable: model != null, + // level: model?.level, + // totalXP: model?.totalXP, + // numLemmas: model?.numConstructs(ConstructTypeEnum.vocab), + // numLemmasUsedCorrectly: vocabLemmasCorrect?.over.length, + // numLemmasUsedIncorrectly: vocabLemmasCorrect?.under.length, + // numLemmasSmallXP: + // vocabLemmas?.thresholdedLemmas(start: 0, end: 30).length, + // numLemmasMediumXP: + // vocabLemmas?.thresholdedLemmas(start: 31, end: 200).length, + // numLemmasLargeXP: vocabLemmas?.thresholdedLemmas(start: 201).length, + // numMorphConstructs: model?.numConstructs(ConstructTypeEnum.morph), + // listMorphConstructs: morphLemmas?.lemmasToUses.entries + // .map((entry) => getCopy(entry.value.first)) + // .toList(), + // listMorphConstructsUsedCorrectlyOriginal: correctOriginalUseLemmas, + // listMorphConstructsUsedIncorrectlyOriginal: incorrectOriginalUseLemmas, + // listMorphConstructsUsedCorrectlySystem: correctSystemUseLemmas, + // listMorphConstructsUsedIncorrectlySystem: incorrectSystemUseLemmas, + // listMorphSmallXP: morphLemmas?.thresholdedLemmas( + // start: 0, + // end: 50, + // getCopy: getCopy, + // ), + // listMorphMediumXP: morphLemmas?.thresholdedLemmas( + // start: 51, + // end: 200, + // getCopy: getCopy, + // ), + // listMorphLargeXP: morphLemmas?.thresholdedLemmas( + // start: 201, + // end: 500, + // getCopy: getCopy, + // ), + // listMorphHugeXP: morphLemmas?.thresholdedLemmas( + // start: 501, + // getCopy: getCopy, + // ), + // numMessagesSent: numMessageSent, + // numWordsTyped: numWordsTyped, + // numChoicesCorrect: numChoicesCorrect, + // numChoicesIncorrect: numChoicesIncorrect, + // numCompletedActivities: numCompletedActivities, + // ); + // } + dynamic getValue(SpaceAnalyticsSummaryEnum key, BuildContext context) { switch (key) { case SpaceAnalyticsSummaryEnum.username: diff --git a/lib/pangea/analytics_misc/construct_list_model.dart b/lib/pangea/analytics_misc/construct_list_model.dart deleted file mode 100644 index cdf926a90..000000000 --- a/lib/pangea/analytics_misc/construct_list_model.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; - -/// A wrapper around a list of [OneConstructUse]s, used to simplify -/// the process of filtering / sorting / displaying the events. -class ConstructListModel { - final List _uses = []; - List get uses => _uses; - List get truncatedUses => _uses.take(100).toList(); - - /// A map of ConstructIdentifiers to ConstructUses, each of which contains a lemma - /// key = lemma + constructType.string, value = ConstructUses - final Map _constructMap = {}; - - /// Storing this to avoid re-running the sort operation each time this needs to - /// be accessed. It contains the same information as _constructMap, but sorted. - List _constructList = []; - - /// [D] is the "compression factor". It determines how quickly - /// or slowly the level grows relative to XP - - final double D = Environment.isStagingEnvironment ? 500 : 1500; - - /// Analytics data consumed by widgets. Updated each time new analytics come in. - int prevXP = 0; - int totalXP = 0; - int level = 0; - - ConstructListModel({ - required List uses, - int offset = 0, - }) { - updateConstructs(uses, offset); - } - - /// Given a list of new construct uses, update the map of construct - /// IDs to ConstructUses and re-sort the list of ConstructUses - void updateConstructs(List newUses, int offset) { - try { - _updateUsesList(newUses); - _updateConstructMap(newUses); - _updateConstructList(); - _updateMetrics(offset); - } catch (err, s) { - ErrorHandler.logError( - e: "Failed to update analytics: $err", - s: s, - data: { - "newUses": newUses.map((e) => e.toJson()), - }, - ); - } - } - - int _sortConstructs(ConstructUses a, ConstructUses b) { - final comp = b.points.compareTo(a.points); - if (comp != 0) return comp; - return a.lemma.compareTo(b.lemma); - } - - void _updateUsesList(List newUses) { - newUses.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); - _uses.insertAll(0, newUses); - } - - /// A map of lemmas to ConstructUses, each of which contains a lemma - /// key = lemmma + constructType.string, value = ConstructUses - void _updateConstructMap(final List newUses) { - for (final use in newUses) { - final currentUses = _constructMap[use.identifier.string] ?? - ConstructUses( - uses: [], - constructType: use.constructType, - lemma: use.lemma, - category: use.category, - ); - currentUses.uses.add(use); - currentUses.setLastUsed(use.timeStamp); - _constructMap[use.identifier.string] = currentUses; - } - - final broadKeys = _constructMap.keys.where((key) => key.endsWith('other')); - final replacedKeys = []; - for (final broadKey in broadKeys) { - final specificKeyPrefix = broadKey.split("-").first; - final specificKey = _constructMap.keys.firstWhereOrNull( - (key) => - key != broadKey && - key.startsWith(specificKeyPrefix) && - !key.endsWith('other'), - ); - if (specificKey == null) continue; - final broadConstructEntry = _constructMap[broadKey]; - final specificConstructEntry = _constructMap[specificKey]; - specificConstructEntry!.uses.addAll(broadConstructEntry!.uses); - _constructMap[specificKey] = specificConstructEntry; - replacedKeys.add(broadKey); - } - - for (final key in replacedKeys) { - _constructMap.remove(key); - } - } - - /// A list of ConstructUses, each of which contains a lemma and - /// a list of uses, sorted by the number of uses - void _updateConstructList() { - // TODO check how expensive this is - _constructList = _constructMap.values.toList(); - _constructList.sort(_sortConstructs); - } - - void _updateMetrics(int offset) { - prevXP = totalXP; - totalXP = (_constructList.fold( - 0, - (total, construct) => total + construct.points, - )) + - offset; - - if (totalXP < 0) { - totalXP = 0; - } - level = calculateLevelWithXp(totalXP); - } - - void deleteConstruct(ConstructIdentifier constructId, int offset) { - _uses.removeWhere((use) => use.identifier == constructId); - _constructMap.removeWhere( - (key, value) => value.id == constructId, - ); - updateConstructs([], offset); - } - - List constructList({ConstructTypeEnum? type}) => _constructList - .where( - (constructUse) => type == null || constructUse.constructType == type, - ) - .toList(); - - // TODO; make this non-nullable, returning empty if not found - ConstructUses? getConstructUses(ConstructIdentifier identifier) { - final partialKey = "${identifier.lemma}-${identifier.type.string}"; - - if (_constructMap.containsKey(identifier.string)) { - // try to get construct use entry with full ID key - return _constructMap[identifier.string]; - } else if (identifier.category == "other") { - // if the category passed to this function is "other", return the first - // construct use entry that starts with the partial key - return _constructMap.entries - .firstWhereOrNull((entry) => entry.key.startsWith(partialKey)) - ?.value; - } else { - // if the category passed to this function is not "other", return the first - // construct use entry that starts with the partial key and ends with "other" - return _constructMap.entries - .firstWhereOrNull( - (entry) => - entry.key.startsWith(partialKey) && entry.key.endsWith("other"), - ) - ?.value; - } - } - - List getConstructUsesByLemma(String lemma) => _constructList - .where( - (constructUse) => constructUse.lemma == lemma, - ) - .toList(); - - int numConstructs(ConstructTypeEnum type) => constructList(type: type).length; - - int calculateLevelWithXp(int totalXP) { - final doubleScore = (1 + sqrt((1 + (8.0 * totalXP / D)) / 2.0)); - if (!doubleScore.isNaN && doubleScore.isFinite) { - return doubleScore.floor(); - } else { - ErrorHandler.logError( - e: "Calculated level in Nan or Infinity", - data: { - "totalXP": totalXP, - "prevXP": prevXP, - "level": doubleScore, - }, - ); - return 1; - } - } - - int calculateXpWithLevel(int level) { - // If level <= 1, XP should be 0 or negative by this math. - // In practice, you might clamp it to 0: - if (level <= 1) { - return 0; - } - - // Convert level to double for the math - final double lc = level.toDouble(); - - // XP from the inverse formula: - final double xpDouble = (D / 8.0) * (2.0 * pow(lc - 1.0, 2.0) - 1.0); - - // Floor or clamp to ensure non-negative. - final int xp = xpDouble.floor(); - return (xp < 0) ? 0 : xp; - } - - /// Unique construct identifiers with XP >= [threshold] - /// Used on analytics update to determine newly 'unlocked' constructs - List unlockedLemmas( - ConstructTypeEnum type, { - int threshold = 0, - }) { - final constructs = constructList(type: type); - final List unlocked = []; - final constructsSet = constructList(type: type).map((e) => e.lemma).toSet(); - - for (final lemma in constructsSet) { - final matches = constructs.where((m) => m.lemma == lemma); - final totalPoints = matches.fold( - 0, - (total, match) => total + match.points, - ); - if (totalPoints > threshold) { - unlocked.add(matches.first.id); - } - } - return unlocked; - } - - // Not storing this for now to reduce memory load - // It's only used by downloads, so doesn't need to be accessible on the fly - Map> lemmasToUses({ - ConstructTypeEnum? type, - }) { - final Map> lemmasToUses = {}; - final constructs = constructList(type: type); - for (final ConstructUses use in constructs) { - final lemma = use.lemma; - lemmasToUses.putIfAbsent(lemma, () => []); - lemmasToUses[lemma]!.add(use); - } - return lemmasToUses; - } -} - -class LemmasToUsesWrapper { - final Map> lemmasToUses; - - LemmasToUsesWrapper(this.lemmasToUses); - - Map> lemmasToFilteredUses( - bool Function(OneConstructUse) filter, - ) { - final Map> lemmasToOneConstructUses = {}; - for (final entry in lemmasToUses.entries) { - final lemma = entry.key; - final uses = entry.value; - lemmasToOneConstructUses[lemma] = - uses.expand((use) => use.uses).toList().where(filter).toList(); - } - return lemmasToOneConstructUses; - } - - LemmasOverUnderList lemmasByPercent({ - required bool Function(OneConstructUse) filter, - required double percent, - required BuildContext context, - }) { - final List correctUseLemmas = []; - final List incorrectUseLemmas = []; - - final uses = lemmasToFilteredUses(filter); - for (final entry in uses.entries) { - if (entry.value.isEmpty) continue; - final List correctUses = []; - final List incorrectUses = []; - - final lemma = getGrammarCopy( - category: entry.value.first.category, - lemma: entry.key, - context: context, - ) ?? - entry.key; - final uses = entry.value.toList(); - - for (final use in uses) { - use.xp > 0 ? correctUses.add(use) : incorrectUses.add(use); - } - - final totalUses = correctUses.length + incorrectUses.length; - final percent = totalUses == 0 ? 0 : correctUses.length / totalUses; - - percent > 0.8 - ? correctUseLemmas.add(lemma) - : incorrectUseLemmas.add(lemma); - } - - return LemmasOverUnderList( - over: correctUseLemmas, - under: incorrectUseLemmas, - ); - } - - /// Return an object containing two lists, one of lemmas with - /// any correct uses and one of lemmas no correct uses - LemmasOverUnderList lemmasByCorrectUse({ - String Function(ConstructUses)? getCopy, - }) { - final List correctLemmas = []; - final List incorrectLemmas = []; - for (final entry in lemmasToUses.entries) { - final lemma = entry.key; - final constructUses = entry.value; - final copy = getCopy?.call(constructUses.first) ?? lemma; - if (constructUses.any((use) => use.hasCorrectUse)) { - correctLemmas.add(copy); - } else { - incorrectLemmas.add(copy); - } - } - return LemmasOverUnderList(over: correctLemmas, under: incorrectLemmas); - } - - int totalXP(String lemma) { - final uses = lemmasToUses[lemma]; - if (uses == null) return 0; - if (uses.length == 1) return uses.first.points; - return lemmasToUses[lemma]!.fold( - 0, - (total, use) => total + use.points, - ); - } - - List thresholdedLemmas({ - required int start, - int? end, - String Function(ConstructUses)? getCopy, - }) { - final filteredList = lemmasToUses.entries.where((entry) { - final xp = totalXP(entry.key); - return xp >= start && (end == null || xp <= end); - }); - return filteredList - .map((entry) => getCopy?.call(entry.value.first) ?? entry.key) - .toList(); - } -} - -class LemmasOverUnderList { - final List over; - final List under; - - LemmasOverUnderList({ - required this.over, - required this.under, - }); -} diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index e03d4c5f9..ea98f34e4 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -1,6 +1,5 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; @@ -10,7 +9,7 @@ class ConstructUses { final List uses; final ConstructTypeEnum constructType; final String lemma; - final String? _category; + String? _category; DateTime? _lastUsed; ConstructUses({ @@ -38,7 +37,9 @@ class ConstructUses { } void setLastUsed(DateTime time) { - _lastUsed = time; + if (_lastUsed == null || time.isAfter(_lastUsed!)) { + _lastUsed = time; + } } String get category { @@ -60,13 +61,33 @@ class ConstructUses { 'construct_id': id.toJson(), 'xp': points, 'last_used': lastUsed?.toIso8601String(), - - /// NOTE - sent to server as just the useTypes - 'uses': uses.map((e) => e.useType.string).toList(), + 'uses': uses.map((e) => e.toJson()).toList(), }; return json; } + factory ConstructUses.fromJson(Map json) { + final constructId = ConstructIdentifier.fromJson( + Map.from(json['construct_id']), + ); + + List usesJson = []; + if (json['uses'] is List) { + usesJson = List.from(json['uses']); + } + + final uses = usesJson + .map((e) => OneConstructUse.fromJson(Map.from(e))) + .toList(); + + return ConstructUses( + uses: uses, + constructType: constructId.type, + lemma: constructId.lemma, + category: constructId.category, + ); + } + /// Get the lemma category, based on points ConstructLevelEnum get lemmaCategory { if (points < AnalyticsConstants.xpForGreens) { @@ -99,4 +120,36 @@ class ConstructUses { return ConstructLevelEnum.flowers; } } + + void merge(ConstructUses other) { + if (other.lemma.toLowerCase() != lemma.toLowerCase() || + other.constructType != constructType) { + throw ArgumentError( + 'Cannot merge ConstructUses with different lemmas or types', + ); + } + + uses.addAll(other.uses); + if (other.lastUsed != null) { + setLastUsed(other.lastUsed!); + } + + if (category == 'other' && other.category != 'other') { + _category = other.category; + } + } + + ConstructUses copyWith({ + List? uses, + ConstructTypeEnum? constructType, + String? lemma, + String? category, + }) { + return ConstructUses( + uses: uses ?? this.uses, + constructType: constructType ?? this.constructType, + lemma: lemma ?? this.lemma, + category: category ?? _category, + ); + } } diff --git a/lib/pangea/analytics_misc/constructs_model.dart b/lib/pangea/analytics_misc/constructs_model.dart index c252202c5..5f0ee420c 100644 --- a/lib/pangea/analytics_misc/constructs_model.dart +++ b/lib/pangea/analytics_misc/constructs_model.dart @@ -151,6 +151,28 @@ class OneConstructUse { 'xp': xp, }; + OneConstructUse copyWith({ + String? lemma, + String? form, + String? category, + ConstructTypeEnum? constructType, + ConstructUseTypeEnum? useType, + String? id, + ConstructUseMetaData? metadata, + int? xp, + }) { + return OneConstructUse( + lemma: lemma ?? this.lemma, + form: form ?? this.form, + category: category ?? this.category, + constructType: constructType ?? this.constructType, + useType: useType ?? this.useType, + id: id ?? this.id, + metadata: metadata ?? this.metadata, + xp: xp ?? this.xp, + ); + } + String get category { if (_category.isEmpty) return "other"; return _category.toLowerCase(); diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart deleted file mode 100644 index 386c91f46..000000000 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ /dev/null @@ -1,650 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:get_storage/get_storage.dart'; -import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.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/constructs_event.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; -import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; -import 'package:fluffychat/pangea/common/constants/local.key.dart'; -import 'package:fluffychat/pangea/common/controllers/base_controller.dart'; -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/constructs/construct_repo.dart'; -import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/languages/language_model.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -/// A minimized version of AnalyticsController that get the logged in user's analytics -class GetAnalyticsController extends BaseController { - static final GetStorage analyticsBox = GetStorage("analytics_storage"); - late PangeaController _pangeaController; - late PracticeSelectionRepo perMessage; - - final List _cache = []; - StreamSubscription? _analyticsUpdateSubscription; - StreamController analyticsStream = - StreamController.broadcast(); - StreamSubscription? _joinSpaceSubscription; - - ConstructListModel constructListModel = ConstructListModel(uses: []); - Completer initCompleter = Completer(); - bool _initializing = false; - - GetAnalyticsController(PangeaController pangeaController) { - _pangeaController = pangeaController; - } - - LanguageModel? get _l1 => _pangeaController.userController.userL1; - LanguageModel? get _l2 => _pangeaController.userController.userL2; - - Client get _client => _pangeaController.matrixState.client; - - // the minimum XP required for a given level - int get _minXPForLevel { - return constructListModel.calculateXpWithLevel(constructListModel.level); - } - - // the minimum XP required for the next level - int get _minXPForNextLevel { - return constructListModel - .calculateXpWithLevel(constructListModel.level + 1); - } - - int get minXPForNextLevel => _minXPForNextLevel; - - // the progress within the current level as a percentage (0.0 to 1.0) - double get levelProgress { - final progress = (constructListModel.totalXP - _minXPForLevel) / - (_minXPForNextLevel - _minXPForLevel); - return progress >= 0 ? progress : 0; - } - - Future initialize() async { - if (_initializing || initCompleter.isCompleted) return; - _initializing = true; - - try { - await GetStorage.init("analytics_storage"); - await GetStorage.init("activity_analytics_storage"); - _client.updateAnalyticsRoomJoinRules(); - _client.addAnalyticsRoomsToSpaces(); - - _analyticsUpdateSubscription ??= _pangeaController - .putAnalytics.analyticsUpdateStream.stream - .listen(_onAnalyticsUpdate); - - _pangeaController.putAnalytics.savedActivitiesNotifier - .addListener(_onActivityAnalyticsUpdate); - - _pangeaController.putAnalytics.blockedConstructsNotifier - .addListener(_onBlockedConstructsUpdate); - - // When a newly-joined space comes through in a sync - // update, add the analytics rooms to the space - _joinSpaceSubscription ??= _client.onSync.stream - .where(_client.isJoinSpaceSyncUpdate) - .listen((_) => _client.addAnalyticsRoomsToSpaces()); - - await _pangeaController.putAnalytics.lastUpdatedCompleter.future; - await _getConstructs(); - - final offset = - _pangeaController.userController.analyticsProfile?.xpOffset ?? 0; - - final allUses = [ - ...(_getConstructsLocal() ?? []), - ..._locallyCachedConstructs, - ]; - - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - final blockedLemmas = analyticsRoom?.analyticsSettings?.blockedConstructs; - if (blockedLemmas != null && blockedLemmas.isNotEmpty) { - allUses.removeWhere( - (use) => blockedLemmas.contains(use.identifier), - ); - } - - constructListModel.updateConstructs( - [ - ...(_getConstructsLocal() ?? []), - ..._locallyCachedConstructs, - ], - offset, - ); - } catch (err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: {}, - ); - } finally { - _updateAnalyticsStream(AnalyticsStreamUpdate()); - if (!initCompleter.isCompleted) initCompleter.complete(); - _initializing = false; - } - } - - /// Clear all cached analytics data. - @override - void dispose() { - constructListModel = ConstructListModel(uses: []); - _analyticsUpdateSubscription?.cancel(); - _analyticsUpdateSubscription = null; - _joinSpaceSubscription?.cancel(); - _joinSpaceSubscription = null; - initCompleter = Completer(); - _pangeaController.putAnalytics.savedActivitiesNotifier - .removeListener(_onActivityAnalyticsUpdate); - _pangeaController.putAnalytics.blockedConstructsNotifier - .removeListener(_onBlockedConstructsUpdate); - _cache.clear(); - } - - Future _onAnalyticsUpdate( - AnalyticsUpdate analyticsUpdate, - ) async { - if (analyticsUpdate.isLogout) return; - - final oldLevel = constructListModel.level; - - final offset = - _pangeaController.userController.analyticsProfile?.xpOffset ?? 0; - - final prevUnlockedMorphs = constructListModel - .unlockedLemmas( - ConstructTypeEnum.morph, - threshold: 30, - ) - .toSet(); - - constructListModel.updateConstructs(analyticsUpdate.newConstructs, offset); - - final newUnlockedMorphs = constructListModel - .unlockedLemmas( - ConstructTypeEnum.morph, - threshold: 30, - ) - .toSet() - .difference(prevUnlockedMorphs); - - if (analyticsUpdate.type == AnalyticsUpdateType.server) { - await _getConstructs(forceUpdate: true); - } - if (oldLevel < constructListModel.level) { - // do not await this - it's not necessary for this to finish - // before the function completes and it blocks the UI - _onLevelUp(oldLevel, constructListModel.level); - } - if (oldLevel > constructListModel.level) { - await _onLevelDown(constructListModel.level, oldLevel); - } - if (newUnlockedMorphs.isNotEmpty) { - _onUnlockMorphLemmas(newUnlockedMorphs); - } - _updateAnalyticsStream( - AnalyticsStreamUpdate( - points: analyticsUpdate.newConstructs.fold( - 0, - (previousValue, element) => previousValue + element.xp, - ), - targetID: analyticsUpdate.targetID, - ), - ); - // Update public profile each time that new analytics are added. - // If the level hasn't changed, this will not send an update to the server. - // Do this on all updates (not just on level updates) to account for cases - // of target language updates being missed (https://github.com/pangeachat/client/issues/2006) - _pangeaController.userController.updateAnalyticsProfile( - level: constructListModel.level, - ); - } - - void _updateAnalyticsStream(AnalyticsStreamUpdate update) => - analyticsStream.add(update); - - void _onLevelUp(final int lowerLevel, final int upperLevel) { - setState({ - 'level_up': constructListModel.level, - 'upper_level': upperLevel, - 'lower_level': lowerLevel, - }); - } - - Future _onLevelDown(final int lowerLevel, final int upperLevel) async { - final offset = constructListModel.calculateXpWithLevel(lowerLevel) - - constructListModel.totalXP; - await _pangeaController.userController.addXPOffset(offset); - constructListModel.updateConstructs( - [], - _pangeaController.userController.analyticsProfile!.xpOffset!, - ); - } - - void _onUnlockMorphLemmas(Set unlocked) { - const excludedLemmas = {'not_proper'}; - - final filtered = { - for (final id in unlocked) - if (!excludedLemmas.contains(id.lemma.toLowerCase())) id, - }; - - setState({'unlocked_constructs': filtered}); - } - - void _onActivityAnalyticsUpdate() => - _updateAnalyticsStream(AnalyticsStreamUpdate()); - - void _onBlockedConstructsUpdate() { - final constructId = - _pangeaController.putAnalytics.blockedConstructsNotifier.value; - if (constructId == null) return; - - constructListModel.deleteConstruct( - constructId, - _pangeaController.userController.analyticsProfile?.xpOffset ?? 0, - ); - _updateAnalyticsStream(AnalyticsStreamUpdate()); - } - - /// A local cache of eventIds and construct uses for messages sent since the last update. - /// It's a map of eventIDs to a list of OneConstructUses. Not just a list of OneConstructUses - /// because, with practice activity constructs, we might need to add to the list for a given - /// eventID. - Map> get messagesSinceUpdate { - try { - final dynamic locallySaved = analyticsBox.read( - PLocalKey.messagesSinceUpdate, - ); - if (locallySaved == null) return {}; - try { - // try to get the local cache of messages and format them as OneConstructUses - final Map> cache = - Map>.from(locallySaved); - final Map> formattedCache = {}; - for (final entry in cache.entries) { - try { - formattedCache[entry.key] = - entry.value.map((e) => OneConstructUse.fromJson(e)).toList(); - } catch (err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "key": entry.key, - }, - ); - continue; - } - } - return formattedCache; - } catch (err) { - // if something goes wrong while trying to format the local data, clear it - clearMessagesCache(); - return {}; - } - } catch (exception, stackTrace) { - ErrorHandler.logError( - e: PangeaWarningError( - "Failed to get messages since update: $exception", - ), - s: stackTrace, - m: 'Failed to retrieve messages since update', - data: { - "messagesSinceUpdate": PLocalKey.messagesSinceUpdate, - }, - ); - return {}; - } - } - - Future clearMessagesCache() async => - analyticsBox.remove(PLocalKey.messagesSinceUpdate); - - Future setMessagesCache(Map cacheValue) async => - analyticsBox.write( - PLocalKey.messagesSinceUpdate, - cacheValue, - ); - - /// A flat list of all locally cached construct uses - List get _locallyCachedConstructs => - messagesSinceUpdate.values.expand((e) => e).toList(); - - /// A flat list of all locally cached construct uses that are not drafts - List get locallyCachedSentConstructs => - messagesSinceUpdate.entries - .where((entry) => !entry.key.startsWith('draft')) - .expand((e) => e.value) - .toList(); - - /// Get a list of all constructs used by the logged in user in their current L2 - Future> _getConstructs({ - bool forceUpdate = false, - ConstructTypeEnum? constructType, - }) async { - // if the user isn't logged in, return an empty list - if (_client.userID == null) return []; - if (_client.prevBatch == null) { - await _client.onSync.stream.first; - } - - // don't try to get constructs until last updated time has been loaded - await _pangeaController.putAnalytics.lastUpdatedCompleter.future; - - // if forcing a refreshing, clear the cache - if (forceUpdate) _cache.clear(); - - final List? local = _getConstructsLocal( - constructType: constructType, - ); - - if (local != null) { - debugPrint("returning local constructs"); - return local; - } - debugPrint("fetching new constructs"); - - // if there is no cached data (or if force updating), - // get all the construct events for the user from analytics room - // and convert their content into a list of construct uses - final List constructEvents = - await _allMyConstructs(); - - final List uses = []; - for (final event in constructEvents) { - uses.addAll(event.content.uses); - } - - // if there isn't already a valid, local cache, cache the filtered uses - if (local == null) { - _cacheConstructs( - constructType: constructType, - uses: uses, - ); - } - - return uses; - } - - /// Get the last time the user updated their analytics for their current l2 - Future myAnalyticsLastUpdated() async { - // this function gets called soon after login, so first - // make sure that the user's l2 is loaded, if the user has set their l2 - if (_client.userID != null && _l2 == null) { - if (_client.prevBatch == null) { - await _client.onSync.stream.first; - } - if (_l2 == null) return null; - } - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - if (analyticsRoom == null) return null; - final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( - _client.userID!, - ); - return lastUpdated; - } - - /// Get all the construct analytics events for the logged in user - Future> _allMyConstructs() async { - if (_l2 == null) return []; - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - if (analyticsRoom == null) return []; - return await analyticsRoom.getAnalyticsEvents(userId: _client.userID!) ?? - []; - } - - /// Get the cached construct uses for the current user, if it exists - List? _getConstructsLocal({ - ConstructTypeEnum? constructType, - }) { - final index = _cache.indexWhere( - (e) => e.type == constructType && e.langCode == _l2?.langCodeShort, - ); - - if (index > -1) { - final DateTime? lastUpdated = _pangeaController.putAnalytics.lastUpdated; - if (_cache[index].needsUpdate(lastUpdated)) { - _cache.removeAt(index); - return null; - } - return _cache[index].uses; - } - - return null; - } - - /// Cache the construct uses for the current user - void _cacheConstructs({ - required List uses, - ConstructTypeEnum? constructType, - }) { - if (_l2 == null) return; - final entry = AnalyticsCacheEntry( - type: constructType, - uses: List.from(uses), - langCode: _l2!.langCodeShort, - ); - _cache.add(entry); - } - - Future _saveConstructSummaryResponseToStateEvent( - final ConstructSummary summary, - ) async { - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - final stateEventId = await _client.setRoomStateWithKey( - analyticsRoom!.id, - PangeaEventTypes.constructSummary, - '', - summary.toJson(), - ); - return stateEventId; - } - - int newConstructCount( - List newConstructs, - ConstructTypeEnum type, - ) { - final uses = newConstructs.where((c) => c.constructType == type); - final Map constructPoints = {}; - for (final use in uses) { - constructPoints[use.identifier] ??= 0; - constructPoints[use.identifier] = - constructPoints[use.identifier]! + use.xp; - } - - int newConstructCount = 0; - for (final entry in constructPoints.entries) { - final construct = constructListModel.getConstructUses(entry.key); - if (construct == null || construct.points == entry.value) { - newConstructCount++; - } - } - - return newConstructCount; - } - - ConstructSummary? getConstructSummaryFromStateEvent() { - try { - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - if (analyticsRoom == null) { - debugPrint("Analytics room is null"); - return null; - } - final state = - analyticsRoom.getState(PangeaEventTypes.constructSummary, ''); - if (state == null) return null; - return ConstructSummary.fromJson(state.content); - } catch (e) { - debugPrint("Error getting construct summary room: $e"); - ErrorHandler.logError(e: e, data: {'e': e}); - return null; - } - } - - Future generateLevelUpAnalytics( - final int lowerLevel, - final int upperLevel, - ) async { - final int maxXP = constructListModel.calculateXpWithLevel(upperLevel); - final int minXP = constructListModel.calculateXpWithLevel(lowerLevel); - int diffXP = maxXP - minXP; - if (diffXP < 0) diffXP = 0; - - // compute construct use of current level - final List constructUseOfCurrentLevel = []; - int score = constructListModel.totalXP; - for (final use in constructListModel.uses) { - constructUseOfCurrentLevel.add(use); - score -= use.xp; - if (score <= minXP) break; - } - - // extract construct use message bodies for analytics - final Map> useEventIds = {}; - for (final use in constructUseOfCurrentLevel) { - if (use.metadata.roomId == null) continue; - if (use.metadata.eventId == null) continue; - useEventIds[use.metadata.roomId!] ??= {}; - useEventIds[use.metadata.roomId!]!.add(use.metadata.eventId!); - } - - final List> messages = []; - for (final entry in useEventIds.entries) { - final String roomId = entry.key; - final room = _client.getRoomById(roomId); - if (room == null) continue; - - final timeline = await room.getTimeline(); - for (final eventId in entry.value) { - try { - final Event? event = await room.getEventById(eventId); - if (event == null) continue; - final pangeaMessageEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: room.client.userID == event.senderId, - ); - - final Map entry = { - "sent": pangeaMessageEvent.originalSent?.text ?? - pangeaMessageEvent.body, - "written": pangeaMessageEvent.originalWrittenContent, - }; - - messages.add(entry); - } catch (e, s) { - debugPrint("Error getting event by ID: $e"); - ErrorHandler.logError( - e: e, - s: s, - data: { - 'roomId': roomId, - 'eventId': eventId, - }, - ); - continue; - } - } - } - - final request = ConstructSummaryRequest( - constructs: constructUseOfCurrentLevel, - messages: messages, - userL1: _l1!.langCodeShort, - userL2: _l2!.langCodeShort, - upperLevel: upperLevel, - lowerLevel: lowerLevel, - ); - - final response = await ConstructRepo.generateConstructSummary(request); - final ConstructSummary summary = response.summary; - summary.levelVocabConstructs = MatrixState - .pangeaController.getAnalytics.constructListModel - .numConstructs(ConstructTypeEnum.vocab); - summary.levelGrammarConstructs = MatrixState - .pangeaController.getAnalytics.constructListModel - .numConstructs(ConstructTypeEnum.morph); - - final Room? analyticsRoom = await _client.getMyAnalyticsRoom(_l2!); - if (analyticsRoom == null) { - throw "Analytics room not found for user"; - } - - // don't await this, just return the original response - _saveConstructSummaryResponseToStateEvent( - summary, - ); - - return summary; - } - - List get archivedActivities { - final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!); - if (analyticsRoom == null) return []; - final ids = analyticsRoom.activityRoomIds; - return ids - .map((id) => _client.getRoomById(id)) - .whereType() - .where( - (room) => - room.membership != Membership.leave && - room.membership != Membership.ban, - ) - .toList(); - } - - int get archivedActivitiesCount { - return archivedActivities.length; - } -} - -class AnalyticsCacheEntry { - final String langCode; - final ConstructTypeEnum? type; - final List uses; - late final DateTime _createdAt; - - AnalyticsCacheEntry({ - required this.langCode, - required this.type, - required this.uses, - }) { - _createdAt = DateTime.now(); - } - - bool needsUpdate(DateTime? lastEventUpdated) { - // cache entry is invalid if it's older than the last event update - // if lastEventUpdated is null, that would indicate that no events - // of this type have been sent to the room. In this case, there - // shouldn't be any cached data. - if (lastEventUpdated == null) { - Sentry.addBreadcrumb( - Breadcrumb(message: "lastEventUpdated is null in needsUpdate"), - ); - return false; - } - return _createdAt.isBefore(lastEventUpdated); - } -} - -class AnalyticsStreamUpdate { - final int points; - final String? targetID; - - AnalyticsStreamUpdate({ - this.points = 0, - this.targetID, - }); -} diff --git a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart index a94c4ce7d..c298f04df 100644 --- a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart +++ b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart @@ -18,16 +18,16 @@ mixin LemmaEmojiSetter { String emoji, String? targetId, ) async { - if (constructId.userSetEmoji.isEmpty) { - _sendEmojiAnalytics( + if (constructId.userSetEmoji == null) { + _getEmojiAnalytics( constructId, targetId: targetId, ); } - await constructId.setUserLemmaInfo( - constructId.userLemmaInfo.copyWith(emojis: [emoji]), - ); + await MatrixState + .pangeaController.matrixState.analyticsDataService.updateService + .setLemmaInfo(constructId, emoji: emoji); } void showLemmaEmojiSnackbar( @@ -46,14 +46,7 @@ mixin LemmaEmojiSetter { children: [ VocabAnalyticsListTile( constructId: constructId, - emoji: emoji, textColor: Theme.of(context).colorScheme.surface, - icon: Text( - emoji, - style: const TextStyle( - fontSize: 22, - ), - ), onTap: () { ScaffoldMessenger.of(context).hideCurrentSnackBar(); context.go( @@ -83,7 +76,7 @@ mixin LemmaEmojiSetter { ); } - void _sendEmojiAnalytics( + void _getEmojiAnalytics( ConstructIdentifier constructId, { String? eventId, String? roomId, @@ -105,11 +98,10 @@ mixin LemmaEmojiSetter { ), ]; - MatrixState.pangeaController.putAnalytics.addAnalytics( + MatrixState.pangeaController.matrixState.analyticsDataService.updateService + .addAnalytics( + targetId, constructs, - eventId: eventId, - roomId: roomId, - targetId: targetId, ); } } diff --git a/lib/pangea/analytics_misc/level_summary.dart b/lib/pangea/analytics_misc/level_summary.dart deleted file mode 100644 index e7534a2b2..000000000 --- a/lib/pangea/analytics_misc/level_summary.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/constructs/construct_repo.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -// New component renamed to ConstructSummaryAlertDialog with a max width -class ConstructSummaryAlertDialog extends StatelessWidget { - final String title; - final String content; - - const ConstructSummaryAlertDialog({ - super.key, - required this.title, - required this.content, - }); - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(title), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Text(content), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(L10n.of(context).close), - ), - ], - ); - } -} - -class LevelSummaryDialog extends StatelessWidget { - final int level; - final String analyticsRoomId; - final String summaryStateEventId; - final ConstructSummary? constructSummary; - - const LevelSummaryDialog({ - super.key, - required this.analyticsRoomId, - required this.level, - required this.summaryStateEventId, - this.constructSummary, - }); - - @override - Widget build(BuildContext context) { - final Client client = Matrix.of(context).client; - final futureSummary = client - .getOneRoomEvent(analyticsRoomId, summaryStateEventId) - .then((rawEvent) => ConstructSummary.fromJson(rawEvent.content)); - if (constructSummary != null) { - return ConstructSummaryAlertDialog( - title: L10n.of(context).levelSummaryPopupTitle(level), - content: constructSummary!.textSummary, - ); - } else { - return FutureBuilder( - future: futureSummary, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } else if (snapshot.hasError) { - return ConstructSummaryAlertDialog( - title: L10n.of(context).levelSummaryPopupTitle(level), - content: L10n.of(context).error502504Desc, - ); - } else if (snapshot.hasData) { - final constructSummary = snapshot.data!; - return ConstructSummaryAlertDialog( - title: L10n.of(context).levelSummaryPopupTitle(level), - content: constructSummary.textSummary, - ); - } else { - return const SizedBox.shrink(); - } - }, - ); - } - } -} diff --git a/lib/pangea/analytics_misc/level_summary_extension.dart b/lib/pangea/analytics_misc/level_summary_extension.dart new file mode 100644 index 000000000..2cab1d179 --- /dev/null +++ b/lib/pangea/analytics_misc/level_summary_extension.dart @@ -0,0 +1,27 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/constructs/construct_repo.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; + +extension LevelSummaryExtension on Room { + ConstructSummary? get levelUpSummary { + final summaryEvent = getState(PangeaEventTypes.constructSummary); + if (summaryEvent != null) { + return ConstructSummary.fromJson(summaryEvent.content); + } + return null; + } + + DateTime? get lastLevelUpTimestamp { + final lastLevelUp = getState(PangeaEventTypes.constructSummary); + return lastLevelUp is Event ? lastLevelUp.originServerTs : null; + } + + Future setLevelUpSummary(ConstructSummary summary) => + client.setRoomStateWithKey( + id, + PangeaEventTypes.constructSummary, + '', + summary.toJson(), + ); +} diff --git a/lib/pangea/analytics_misc/level_up/level_popup_progess_bar.dart b/lib/pangea/analytics_misc/level_up/level_popup_progess_bar.dart index a7dbb34ce..25d74c8cf 100644 --- a/lib/pangea/analytics_misc/level_up/level_popup_progess_bar.dart +++ b/lib/pangea/analytics_misc/level_up/level_popup_progess_bar.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; class LevelPopupProgressBar extends StatefulWidget { final double height; diff --git a/lib/pangea/analytics_misc/level_up/level_up_banner.dart b/lib/pangea/analytics_misc/level_up/level_up_banner.dart index f9cb8c449..a242bac38 100644 --- a/lib/pangea/analytics_misc/level_up/level_up_banner.dart +++ b/lib/pangea/analytics_misc/level_up/level_up_banner.dart @@ -9,6 +9,8 @@ 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/analytics_constants.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_popup.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; @@ -105,9 +107,11 @@ class LevelUpBannerState extends State _loadConstructSummary(); + final analyticsService = Matrix.of(context).analyticsDataService; LevelUpManager.instance.preloadAnalytics( widget.level, widget.prevLevel, + analyticsService, ); _slideController = AnimationController( @@ -162,9 +166,19 @@ class LevelUpBannerState extends State Future _loadConstructSummary() async { try { - final summary = MatrixState.pangeaController.getAnalytics - .generateLevelUpAnalytics(widget.prevLevel, widget.level); + final analyticsRoom = await Matrix.of(context).client.getMyAnalyticsRoom( + MatrixState.pangeaController.userController.userL2!, + ); + + final timestamp = analyticsRoom!.lastLevelUpTimestamp; + final analyticsService = Matrix.of(context).analyticsDataService; + final summary = await analyticsService.levelUpService.getLevelUpAnalytics( + widget.prevLevel, + widget.level, + timestamp, + ); _constructSummaryCompleter.complete(summary); + analyticsRoom.setLevelUpSummary(summary); } catch (e) { debugPrint("Error generating level up analytics: $e"); _constructSummaryCompleter.completeError(e); diff --git a/lib/pangea/analytics_misc/level_up/level_up_manager.dart b/lib/pangea/analytics_misc/level_up/level_up_manager.dart index d124e399d..b358d7ad2 100644 --- a/lib/pangea/analytics_misc/level_up/level_up_manager.dart +++ b/lib/pangea/analytics_misc/level_up/level_up_manager.dart @@ -1,9 +1,9 @@ import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/constructs/construct_repo.dart'; -import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -27,6 +27,7 @@ class LevelUpManager { Future preloadAnalytics( int level, int prevLevel, + AnalyticsDataService analyticsService, ) async { this.level = level; this.prevLevel = prevLevel; @@ -34,10 +35,8 @@ class LevelUpManager { //For on route change behavior, if added in the future shouldAutoPopup = true; - nextGrammar = MatrixState.pangeaController.getAnalytics.constructListModel - .numConstructs(ConstructTypeEnum.morph); - nextVocab = MatrixState.pangeaController.getAnalytics.constructListModel - .numConstructs(ConstructTypeEnum.vocab); + nextGrammar = analyticsService.numConstructs(ConstructTypeEnum.morph); + nextVocab = analyticsService.numConstructs(ConstructTypeEnum.vocab); final LanguageModel? l2 = MatrixState.pangeaController.userController.userL2; @@ -45,25 +44,7 @@ class LevelUpManager { MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2!); if (analyticsRoom != null) { - // How to get all summary events in the timeline - final timeline = await analyticsRoom.getTimeline(); - final summaryEvents = timeline.events - .where( - (e) => e.type == PangeaEventTypes.constructSummary, - ) - .map( - (e) => ConstructSummary.fromJson(e.content), - ) - .toList(); - - //Find previous summary to get grammar constructs and vocab numbers from - final lastSummary = summaryEvents - .where((summary) => summary.upperLevel == prevLevel) - .toList() - .isNotEmpty - ? summaryEvents - .firstWhere((summary) => summary.upperLevel == prevLevel) - : null; + final lastSummary = analyticsRoom.levelUpSummary; //Set grammar and vocab from last level summary, if there is one. Otherwise set to placeholder data if (lastSummary != null && diff --git a/lib/pangea/analytics_misc/message_analytics_feedback.dart b/lib/pangea/analytics_misc/message_analytics_feedback.dart index bc13eeee6..e46ca16fe 100644 --- a/lib/pangea/analytics_misc/message_analytics_feedback.dart +++ b/lib/pangea/analytics_misc/message_analytics_feedback.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; class MessageAnalyticsFeedback extends StatefulWidget { final int newGrammarConstructs; diff --git a/lib/pangea/analytics_misc/put_analytics_controller.dart b/lib/pangea/analytics_misc/put_analytics_controller.dart deleted file mode 100644 index 5679bdf5d..000000000 --- a/lib/pangea/analytics_misc/put_analytics_controller.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; -import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart'; -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/languages/language_model.dart'; -import 'package:fluffychat/pangea/user/user_controller.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -enum AnalyticsUpdateType { server, local } - -/// handles the processing of analytics for -/// 1) messages sent by the user and -/// 2) constructs used by the user, both in sending messages and doing practice activities -class PutAnalyticsController { - late PangeaController _pangeaController; - StreamController analyticsUpdateStream = - StreamController.broadcast(); - - ValueNotifier> savedActivitiesNotifier = ValueNotifier([]); - ValueNotifier blockedConstructsNotifier = - ValueNotifier(null); - - StreamSubscription? _languageStream; - Timer? _updateTimer; - - Client get _client => _pangeaController.matrixState.client; - - /// the last time that matrix analytics events were updated for the user's current l2 - DateTime? lastUpdated; - - /// Last updated completer. Used to wait for the last - /// updated time to be set before setting analytics data. - Completer lastUpdatedCompleter = Completer(); - - /// the max number of messages that will be cached before - /// an automatic update is triggered - final int _maxMessagesCached = 10; - - /// the number of minutes before an automatic update is triggered - final int _minutesBeforeUpdate = 5; - - /// the time since the last update that will trigger an automatic update - final Duration _timeSinceUpdate = const Duration(days: 1); - - PutAnalyticsController(PangeaController pangeaController) { - _pangeaController = pangeaController; - } - - void initialize() { - _languageStream ??= _pangeaController.userController.languageStream.stream - .listen(_onUpdateLanguages); - _refreshAnalyticsIfOutdated(); - } - - /// Reset analytics last updated time to null. - void dispose() { - _updateTimer?.cancel(); - lastUpdated = null; - lastUpdatedCompleter = Completer(); - _languageStream?.cancel(); - _languageStream = null; - MatrixState.pangeaController.getAnalytics.clearMessagesCache(); - } - - /// If analytics haven't been updated in the last day, update them - Future _refreshAnalyticsIfOutdated() async { - // don't set anything is the user is not logged in - if (_client.userID == null) return; - try { - // if lastUpdated hasn't been set yet, set it - lastUpdated ??= - await _pangeaController.getAnalytics.myAnalyticsLastUpdated(); - } catch (err, s) { - ErrorHandler.logError( - s: s, - e: err, - m: "Failed to get last updated time for analytics", - data: {}, - ); - } finally { - // if this is the initial load, complete the lastUpdatedCompleter - if (!lastUpdatedCompleter.isCompleted) { - lastUpdatedCompleter.complete(lastUpdated); - } - } - - final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate); - if (lastUpdated?.isBefore(yesterday) ?? true) { - debugPrint("analytics out-of-date, updating"); - await sendLocalAnalyticsToAnalyticsRoom(); - } - } - - /// Given new construct uses, format and cache - /// the data locally and reset the update timer - /// Decide whether to update the analytics room - void addAnalytics( - List constructs, { - String? eventId, - String? roomId, - String? targetId, - }) { - final level = _pangeaController.getAnalytics.constructListModel.level; - _addLocalMessage(eventId, List.from(constructs)).then( - (_) => _sendAnalytics(level, targetId, constructs), - ); - } - - /// Add a list of construct uses for a new message to the local - /// cache of recently sent messages - Future _addLocalMessage( - String? cacheKey, - List constructs, - ) async { - try { - final currentCache = _pangeaController.getAnalytics.messagesSinceUpdate; - constructs.addAll(currentCache[cacheKey] ?? []); - - // if this is not a draft message, add the eventId to the metadata - // if it's missing (it will be missing for draft constructs) - if (cacheKey != null) { - constructs = constructs.map((construct) { - if (construct.metadata.eventId != null) return construct; - construct.metadata.eventId = cacheKey; - return construct; - }).toList(); - } - - cacheKey ??= Object.hashAll(constructs).toString(); - currentCache[cacheKey] = constructs; - - await _setMessagesSinceUpdate(currentCache); - } catch (e, s) { - ErrorHandler.logError( - e: PangeaWarningError("Failed to add message since update: $e"), - s: s, - m: 'Failed to add message since update for eventId: $cacheKey', - data: { - "cacheKey": cacheKey, - }, - ); - } - } - - /// Handles cleanup after adding a new message to the local cache. - /// If the addition brought the total number of messages in the cache - /// to the max, or if the addition triggered a level-up, update the analytics. - /// Otherwise, add a local update to the alert stream. - void _sendAnalytics( - int prevLevel, - String? targetID, - List newConstructs, - ) { - // cancel the last timer that was set on message event and - // reset it to fire after _minutesBeforeUpdate minutes - _updateTimer?.cancel(); - _updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () { - debugPrint("timer fired, updating analytics"); - sendLocalAnalyticsToAnalyticsRoom(); - }); - - if (_pangeaController.getAnalytics.messagesSinceUpdate.length > - _maxMessagesCached) { - debugPrint("reached max messages, updating"); - sendLocalAnalyticsToAnalyticsRoom(); - return; - } - analyticsUpdateStream.add( - AnalyticsUpdate( - AnalyticsUpdateType.local, - newConstructs, - targetID: targetID, - ), - ); - } - - Future _onUpdateLanguages(LanguageUpdate update) async { - await sendLocalAnalyticsToAnalyticsRoom( - l2Override: update.prevTargetLang, - ); - _pangeaController.resetAnalytics().then((_) { - final level = _pangeaController.getAnalytics.constructListModel.level; - _pangeaController.userController.updateAnalyticsProfile(level: level); - }); - } - - /// Save the local cache of recently sent constructs to the local storage - Future _setMessagesSinceUpdate( - Map> cache, - ) async { - final formattedCache = {}; - for (final entry in cache.entries) { - final constructJsons = entry.value.map((e) => e.toJson()).toList(); - formattedCache[entry.key] = constructJsons; - } - await MatrixState.pangeaController.getAnalytics - .setMessagesCache(formattedCache); - } - - /// Prevent concurrent updates to analytics - Completer? _updateCompleter; - - /// Updates learning analytics. - /// - /// This method is responsible for updating the analytics. It first checks if an update is already in progress - /// by checking the completion status of the [_updateCompleter]. If an update is already in progress, it waits - /// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and - /// proceeds with the update process. If the update is successful, it clears any messages that were received - /// since the last update and notifies the [analyticsUpdateStream]. - Future sendLocalAnalyticsToAnalyticsRoom({ - onLogout = false, - LanguageModel? l2Override, - }) async { - if (_client.userID == null) return; - if (_pangeaController.getAnalytics.messagesSinceUpdate.isEmpty) return; - - if (!(_updateCompleter?.isCompleted ?? true)) { - await _updateCompleter!.future; - return; - } - _updateCompleter = Completer(); - try { - await _updateAnalytics(l2Override: l2Override); - MatrixState.pangeaController.getAnalytics.clearMessagesCache(); - - lastUpdated = DateTime.now(); - analyticsUpdateStream.add( - AnalyticsUpdate( - AnalyticsUpdateType.server, - [], - isLogout: onLogout, - ), - ); - } catch (err, s) { - ErrorHandler.logError( - e: err, - m: "Failed to update analytics", - s: s, - data: { - "l2Override": l2Override, - }, - ); - } finally { - _updateCompleter?.complete(); - _updateCompleter = null; - } - } - - /// Updates the analytics by sending cached analytics data to the analytics room. - /// The analytics room is determined based on the user's current target language. - Future _updateAnalytics({LanguageModel? l2Override}) async { - // if there's no cached construct data, there's nothing to send - final cachedConstructs = _pangeaController.getAnalytics.messagesSinceUpdate; - final bool onlyDraft = cachedConstructs.length == 1 && - cachedConstructs.keys.single.startsWith('draft'); - if (cachedConstructs.isEmpty || onlyDraft) return; - - // if missing important info, don't send analytics. Could happen if user just signed up. - final l2 = l2Override ?? _pangeaController.userController.userL2; - if (l2 == null || _client.userID == null) return; - - // analytics room for the user and current target language - final Room? analyticsRoom = await _client.getMyAnalyticsRoom(l2); - - // and send cached analytics data to the room - await analyticsRoom?.sendConstructsEvent( - _pangeaController.getAnalytics.locallyCachedSentConstructs, - ); - } - - Future sendActivityAnalytics(String roomId) async { - if (_client.userID == null) return; - if (_pangeaController.userController.userL2 == null) return; - - final Room? analyticsRoom = await _client.getMyAnalyticsRoom( - _pangeaController.userController.userL2!, - ); - if (analyticsRoom == null) return; - await analyticsRoom.addActivityRoomId(roomId); - savedActivitiesNotifier.value = analyticsRoom.activityRoomIds; - } - - Future blockConstruct(ConstructIdentifier constructId) async { - if (_pangeaController.matrixState.client.userID == null) return; - if (_pangeaController.userController.userL2 == null) return; - - final Room? analyticsRoom = await _client.getMyAnalyticsRoom( - _pangeaController.userController.userL2!, - ); - if (analyticsRoom == null) return; - - final current = analyticsRoom.analyticsSettings ?? - const AnalyticsSettingsModel(blockedConstructs: {}); - - final blockedConstructs = current.blockedConstructs; - final updated = current.copyWith( - blockedConstructs: { - ...blockedConstructs, - constructId, - }, - ); - - await analyticsRoom.setAnalyticsSettings(updated); - blockedConstructsNotifier.value = constructId; - } -} - -class AnalyticsUpdate { - final AnalyticsUpdateType type; - final List newConstructs; - final bool isLogout; - final String? targetID; - - AnalyticsUpdate( - this.type, - this.newConstructs, { - this.isLogout = false, - this.targetID, - }); -} diff --git a/lib/pangea/analytics_misc/room_analytics_extension.dart b/lib/pangea/analytics_misc/room_analytics_extension.dart index 2d4e6f6c7..375bce4c0 100644 --- a/lib/pangea/analytics_misc/room_analytics_extension.dart +++ b/lib/pangea/analytics_misc/room_analytics_extension.dart @@ -1,184 +1,16 @@ part of "../extensions/pangea_room_extension.dart"; extension AnalyticsRoomExtension on Room { - /// Get next n analytics rooms via the space hierarchy - /// If joined - /// If not in target language - /// If not created by user, leave - /// Else, add to list - /// Else - /// If room name does not match L2, skip - /// Join and wait for room in sync. - /// Repeat the same procedure as above. - /// - /// If not n analytics rooms in list, and nextBatch != null, repeat the above - /// procedure with nextBatch until n analytics rooms are found or nextBatch == null - /// - /// Yield this list of rooms. - /// Once analytics have been retrieved, leave analytics rooms not created by self. - Stream> getNextAnalyticsRoomBatch(String langCode) async* { - final List rooms = []; - String? nextBatch; - int spaceHierarchyCalls = 0; - int callsToServer = 0; - - while (spaceHierarchyCalls <= 5 && - (nextBatch != null || spaceHierarchyCalls == 0)) { - spaceHierarchyCalls++; - final resp = await _getNextBatch(nextBatch); - callsToServer++; - if (resp == null) return; - - rooms.addAll(resp.rooms); - nextBatch = resp.nextBatch; - - final List roomsBatch = []; - while (rooms.isNotEmpty) { - // prevent rate-limiting - if (callsToServer >= 5) { - callsToServer = 0; - await Future.delayed(const Duration(milliseconds: 7500)); - } - - final nextRoomChunk = rooms.removeAt(0); - if (nextRoomChunk.roomType != PangeaRoomTypes.analytics) { - continue; - } - - final matchingRoom = client.rooms.firstWhereOrNull( - (r) => r.id == nextRoomChunk.roomId, - ); - - final (analyticsRoom, calls) = matchingRoom != null - ? await _handleJoinedAnalyticsRoom(matchingRoom, langCode) - : await _handleUnjoinedAnalyticsRoom(nextRoomChunk, langCode); - - callsToServer += calls; - if (analyticsRoom == null) continue; - roomsBatch.add(analyticsRoom); - - if (roomsBatch.length >= 5) { - final roomsBatchCopy = List.from(roomsBatch); - roomsBatch.clear(); - yield roomsBatchCopy; - } - } - - yield roomsBatch; - } + String? get madeForLang { + final creationContent = getState(EventTypes.RoomCreate)?.content; + return creationContent?.tryGet(ModelKey.langCode) ?? + creationContent?.tryGet(ModelKey.oldLangCode); } - /// Return analytics room, given unjoined member of space hierarchy, - /// if should get analytics for that room, and number of call made - /// to the server to help prevent rate-limiting - Future<(Room?, int)> _handleUnjoinedAnalyticsRoom( - SpaceRoomsChunk chunk, - String l2, - ) async { - int callsToServer = 0; - final nameParts = chunk.name?.split(" "); - if (nameParts != null && nameParts.length >= 2) { - final roomLangCode = nameParts[1]; - if (roomLangCode != l2) return (null, callsToServer); - } - - Room? analyticsRoom = await _joinAnalyticsRoomChunk(chunk); - callsToServer++; - - if (analyticsRoom == null) return (null, callsToServer); - final (room, calls) = await _handleJoinedAnalyticsRoom(analyticsRoom, l2); - analyticsRoom = room; - callsToServer += calls; - - return (analyticsRoom, callsToServer); - } - - /// Return analytics room if should add to returned list - /// and the number of calls made to the server (used to prevent rate-limiting) - Future<(Room?, int)> _handleJoinedAnalyticsRoom( - Room analyticsRoom, - String l2, - ) async { - if (client.userID == null) return (null, 0); - if (analyticsRoom.madeForLang != l2) { - await _leaveNonTargetAnalyticsRoom(analyticsRoom, l2); - return (null, 1); - } - return (analyticsRoom, 0); - } - - Future _joinAnalyticsRoomChunk( - SpaceRoomsChunk chunk, - ) async { - final matchingRoom = client.rooms.firstWhereOrNull( - (r) => r.id == chunk.roomId, - ); - if (matchingRoom != null) return matchingRoom; - - try { - final syncFuture = client.waitForRoomInSync(chunk.roomId, join: true); - await client.joinRoom(chunk.roomId); - await syncFuture; - return client.getRoomById(chunk.roomId); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "roomID": chunk.roomId, - }, - ); - return null; - } - } - - Future _leaveNonTargetAnalyticsRoom(Room room, String userL2) async { - if (client.userID == null || - room.isMadeByUser(client.userID!) || - room.madeForLang == userL2) { - return; - } - - try { - await room.leave(); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "roomID": room.id, - }, - ); - } - } - - Future _getNextBatch(String? nextBatch) async { - try { - final resp = await client.getSpaceHierarchy( - id, - from: nextBatch, - limit: 100, - maxDepth: 1, - ); - return resp; - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "spaceID": id, - "nextBatch": nextBatch, - }, - ); - return null; - } - } - - Future analyticsLastUpdated(String userId) async { - final List events = - await getRoomAnalyticsEvents(count: 1, userID: userId); - if (events.isEmpty) return null; - return events.first.originServerTs; + bool isMadeForLang(String langCode) { + final creationContent = getState(EventTypes.RoomCreate)?.content; + return creationContent?.tryGet(ModelKey.langCode) == langCode || + creationContent?.tryGet(ModelKey.oldLangCode) == langCode; } Future?> getAnalyticsEvents({ @@ -194,18 +26,6 @@ extension AnalyticsRoomExtension on Room { return analyticsEvents; } - String? get madeForLang { - final creationContent = getState(EventTypes.RoomCreate)?.content; - return creationContent?.tryGet(ModelKey.langCode) ?? - creationContent?.tryGet(ModelKey.oldLangCode); - } - - bool isMadeForLang(String langCode) { - final creationContent = getState(EventTypes.RoomCreate)?.content; - return creationContent?.tryGet(ModelKey.langCode) == langCode || - creationContent?.tryGet(ModelKey.oldLangCode) == langCode; - } - /// Sends construct events to the server. /// /// The [uses] parameter is a list of [OneConstructUse] objects representing the @@ -268,87 +88,4 @@ extension AnalyticsRoomExtension on Room { ); } } - - UserSetLemmaInfo? getUserSetLemmaInfo(ConstructIdentifier cId) { - final state = getState(PangeaEventTypes.userSetLemmaInfo, cId.string); - if (state == null) return null; - try { - return UserSetLemmaInfo.fromJson(state.content); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "roomID": id, - "stateContent": state.content, - "stateKey": state.stateKey, - }, - ); - return null; - } - } - - Future setUserSetLemmaInfo( - ConstructIdentifier cId, - UserSetLemmaInfo info, - ) async { - final syncFuture = client.onRoomState.stream.firstWhere((event) { - return event.roomId == id && - event.state.type == PangeaEventTypes.userSetLemmaInfo; - }); - client.setRoomStateWithKey( - id, - PangeaEventTypes.userSetLemmaInfo, - cId.string, - info.toJson(), - ); - await syncFuture.timeout(const Duration(seconds: 10)); - } - - List get activityRoomIds { - final state = getState(PangeaEventTypes.activityRoomIds); - if (state?.content[ModelKey.roomIds] is List) { - return List.from(state!.content[ModelKey.roomIds] as List); - } - return []; - } - - Future addActivityRoomId(String roomId) async { - final List ids = List.from(activityRoomIds); - if (ids.contains(roomId)) return; - - final prevLength = ids.length; - ids.add(roomId); - - final syncFuture = client.waitForRoomInSync(id, join: true); - await client.setRoomStateWithKey( - id, - PangeaEventTypes.activityRoomIds, - "", - {ModelKey.roomIds: ids}, - ); - final newLength = activityRoomIds.length; - if (newLength == prevLength) { - await syncFuture; - } - } - - Future removeActivityRoomId(String roomId) async { - final List ids = List.from(activityRoomIds); - if (!ids.contains(roomId)) return; - final prevLength = ids.length; - ids.remove(roomId); - - final syncFuture = client.waitForRoomInSync(id, join: true); - await client.setRoomStateWithKey( - id, - PangeaEventTypes.activityRoomIds, - "", - {ModelKey.roomIds: ids}, - ); - final newLength = activityRoomIds.length; - if (newLength == prevLength) { - await syncFuture; - } - } } diff --git a/lib/pangea/analytics_misc/saved_analytics_extension.dart b/lib/pangea/analytics_misc/saved_analytics_extension.dart new file mode 100644 index 000000000..9e362baa8 --- /dev/null +++ b/lib/pangea/analytics_misc/saved_analytics_extension.dart @@ -0,0 +1,48 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; + +extension SavedAnalyticsExtension on Room { + List get _activityRoomIds { + final state = getState(PangeaEventTypes.activityRoomIds); + if (state?.content[ModelKey.roomIds] is List) { + return List.from(state!.content[ModelKey.roomIds] as List); + } + return []; + } + + List get archivedActivities { + return _activityRoomIds + .map((id) => client.getRoomById(id)) + .whereType() + .where( + (room) => + room.membership != Membership.leave && + room.membership != Membership.ban, + ) + .toList(); + } + + int get archivedActivitiesCount => archivedActivities.length; + + Future addActivityRoomId(String roomId) async { + final List ids = List.from(_activityRoomIds); + if (ids.contains(roomId)) return; + + final prevLength = ids.length; + ids.add(roomId); + + final syncFuture = client.waitForRoomInSync(id, join: true); + await client.setRoomStateWithKey( + id, + PangeaEventTypes.activityRoomIds, + "", + {ModelKey.roomIds: ids}, + ); + final newLength = _activityRoomIds.length; + if (newLength == prevLength) { + await syncFuture; + } + } +} diff --git a/lib/pangea/analytics_misc/user_lemma_info_extension.dart b/lib/pangea/analytics_misc/user_lemma_info_extension.dart new file mode 100644 index 000000000..3a38206c0 --- /dev/null +++ b/lib/pangea/analytics_misc/user_lemma_info_extension.dart @@ -0,0 +1,49 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; + +extension UserLemmaInfoExtension on Room { + UserSetLemmaInfo getUserSetLemmaInfo(ConstructIdentifier cId) { + final state = getState(PangeaEventTypes.userSetLemmaInfo, cId.string); + if (state == null) return UserSetLemmaInfo(); + try { + return UserSetLemmaInfo.fromJson(state.content); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "roomID": id, + "stateContent": state.content, + "stateKey": state.stateKey, + }, + ); + return UserSetLemmaInfo(); + } + } + + String? constructEmoji(ConstructIdentifier cId) { + final info = getUserSetLemmaInfo(cId); + return info.emojis?.firstOrNull; + } + + Future setUserSetLemmaInfo( + ConstructIdentifier cId, + UserSetLemmaInfo info, + ) async { + final syncFuture = client.onRoomState.stream.firstWhere((event) { + return event.roomId == id && + event.state.type == PangeaEventTypes.userSetLemmaInfo; + }); + client.setRoomStateWithKey( + id, + PangeaEventTypes.userSetLemmaInfo, + cId.string, + info.toJson(), + ); + await syncFuture.timeout(const Duration(seconds: 10)); + } +} diff --git a/lib/pangea/analytics_page/activity_archive.dart b/lib/pangea/analytics_page/activity_archive.dart index ed4164d77..0085a43d1 100644 --- a/lib/pangea/analytics_page/activity_archive.dart +++ b/lib/pangea/analytics_page/activity_archive.dart @@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; @@ -21,11 +23,10 @@ class ActivityArchive extends StatelessWidget { this.selectedRoomId, }); - List get archive => - MatrixState.pangeaController.getAnalytics.archivedActivities; - @override Widget build(BuildContext context) { + final Room? analyticsRoom = Matrix.of(context).client.analyticsRoomLocal(); + final archive = analyticsRoom?.archivedActivities ?? []; return MaxWidthBody( withScrolling: false, child: ListView.builder( diff --git a/lib/pangea/analytics_page/analytics_page.dart b/lib/pangea/analytics_page/analytics_page.dart index 79d98368e..f4dc62632 100644 --- a/lib/pangea/analytics_page/analytics_page.dart +++ b/lib/pangea/analytics_page/analytics_page.dart @@ -11,14 +11,14 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_page/activity_archive.dart'; import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart'; import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart'; -import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart'; +import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_content.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class AnalyticsPage extends StatelessWidget { +class AnalyticsPage extends StatefulWidget { final ProgressIndicatorEnum? indicator; final ConstructIdentifier? construct; final bool isSidebar; @@ -30,7 +30,18 @@ class AnalyticsPage extends StatelessWidget { this.isSidebar = false, }); - Future _blockLemma(BuildContext context) async { + @override + AnalyticsPageState createState() => AnalyticsPageState(); +} + +class AnalyticsPageState extends State { + @override + void initState() { + super.initState(); + MatrixState.pangeaController.initControllers(); + } + + Future _blockLemma() async { final resp = await showOkCancelAlertDialog( context: context, title: L10n.of(context).areYouSure, @@ -41,8 +52,10 @@ class AnalyticsPage extends StatelessWidget { if (resp != OkCancelResult.ok) return; final res = await showFutureLoadingDialog( context: context, - future: () => - MatrixState.pangeaController.putAnalytics.blockConstruct(construct!), + future: () => Matrix.of(context) + .analyticsDataService + .updateService + .blockConstruct(widget.construct!), ); if (!res.isError) { @@ -54,79 +67,73 @@ class AnalyticsPage extends StatelessWidget { Widget build(BuildContext context) { final analyticsRoomId = GoRouterState.of(context).pathParameters['roomid']; return Scaffold( - appBar: construct != null + appBar: widget.construct != null ? AppBar( - actions: indicator == ProgressIndicatorEnum.wordsUsed + actions: widget.indicator == ProgressIndicatorEnum.wordsUsed ? [ IconButton( icon: const Icon(Icons.delete_forever_outlined), color: Theme.of(context).colorScheme.error, tooltip: L10n.of(context).delete, - onPressed: () => _blockLemma(context), + onPressed: _blockLemma, ), ] : null, ) : null, body: SafeArea( - child: FutureBuilder( - future: - MatrixState.pangeaController.getAnalytics.initCompleter.future, - builder: (context, snapshot) { - return Padding( - padding: const EdgeInsetsGeometry.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isSidebar || - (!FluffyThemes.isColumnMode(context) && - construct == null)) - LearningProgressIndicators( - selected: indicator, - canSelect: indicator != ProgressIndicatorEnum.level, - ), - Expanded( - child: () { - if (indicator == ProgressIndicatorEnum.level) { - return const LevelDialogContent(); - } else if (indicator == - ProgressIndicatorEnum.morphsUsed) { - return ConstructAnalyticsView( - construct: construct, - view: ConstructTypeEnum.morph, - ); - } else if (indicator == ProgressIndicatorEnum.wordsUsed) { - return ConstructAnalyticsView( - construct: construct, - view: ConstructTypeEnum.vocab, - ); - } else if (indicator == - ProgressIndicatorEnum.activities) { - return ActivityArchive( - selectedRoomId: analyticsRoomId, - ); - } + child: Padding( + padding: const EdgeInsetsGeometry.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.isSidebar || + (!FluffyThemes.isColumnMode(context) && + widget.construct == null)) + LearningProgressIndicators( + selected: widget.indicator, + canSelect: widget.indicator != ProgressIndicatorEnum.level, + ), + Expanded( + child: () { + if (widget.indicator == ProgressIndicatorEnum.level) { + return const LevelAnalyticsDetailsContent(); + } else if (widget.indicator == + ProgressIndicatorEnum.morphsUsed) { + return ConstructAnalyticsView( + construct: widget.construct, + view: ConstructTypeEnum.morph, + ); + } else if (widget.indicator == + ProgressIndicatorEnum.wordsUsed) { + return ConstructAnalyticsView( + construct: widget.construct, + view: ConstructTypeEnum.vocab, + ); + } else if (widget.indicator == + ProgressIndicatorEnum.activities) { + return ActivityArchive( + selectedRoomId: analyticsRoomId, + ); + } - return Center( - child: SizedBox( - width: 250.0, - child: CachedNetworkImage( - imageUrl: - "${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}", - errorWidget: (context, url, error) => - const SizedBox(), - placeholder: (context, url) => const Center( - child: CircularProgressIndicator.adaptive(), - ), - ), + return Center( + child: SizedBox( + width: 250.0, + child: CachedNetworkImage( + imageUrl: + "${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}", + errorWidget: (context, url, error) => const SizedBox(), + placeholder: (context, url) => const Center( + child: CircularProgressIndicator.adaptive(), ), - ); - }(), - ), - ], + ), + ), + ); + }(), ), - ); - }, + ], + ), ), ), ); diff --git a/lib/pangea/analytics_settings/analytics_settings_extension.dart b/lib/pangea/analytics_settings/analytics_settings_extension.dart index f3accbc37..7e7316ea9 100644 --- a/lib/pangea/analytics_settings/analytics_settings_extension.dart +++ b/lib/pangea/analytics_settings/analytics_settings_extension.dart @@ -1,15 +1,21 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; extension AnalyticsSettingsRoomExtension on Room { - AnalyticsSettingsModel? get analyticsSettings { + AnalyticsSettingsModel get analyticsSettings { final event = getState(PangeaEventTypes.analyticsSettings); - if (event == null) return null; + if (event == null) { + return const AnalyticsSettingsModel(blockedConstructs: {}); + } return AnalyticsSettingsModel.fromJson(event.content); } + Set get blockedConstructs => + analyticsSettings.blockedConstructs; + Future setAnalyticsSettings( AnalyticsSettingsModel settings, ) async { diff --git a/lib/pangea/analytics_summary/progress_bar/animated_progress_bar.dart b/lib/pangea/analytics_summary/animated_progress_bar.dart similarity index 100% rename from lib/pangea/analytics_summary/progress_bar/animated_progress_bar.dart rename to lib/pangea/analytics_summary/animated_progress_bar.dart diff --git a/lib/pangea/analytics_summary/learning_progress_bar.dart b/lib/pangea/analytics_summary/learning_progress_bar.dart index 7ee7bee2d..2292c2b94 100644 --- a/lib/pangea/analytics_summary/learning_progress_bar.dart +++ b/lib/pangea/analytics_summary/learning_progress_bar.dart @@ -1,18 +1,15 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; class LearningProgressBar extends StatelessWidget { - final int level; - final int totalXP; + final double progress; final double height; final bool loading; const LearningProgressBar({ - required this.level, - required this.totalXP, + required this.progress, required this.loading, required this.height, super.key, @@ -32,7 +29,7 @@ class LearningProgressBar extends StatelessWidget { return AnimatedProgressBar( height: height, - widthPercent: MatrixState.pangeaController.getAnalytics.levelProgress, + widthPercent: progress, barColor: AppConfig.goldLight, backgroundColor: Theme.of(context).colorScheme.secondaryContainer, ); diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index 21b628107..336bacaa0 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -1,13 +1,11 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; +import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart'; 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'; @@ -20,7 +18,7 @@ import 'package:fluffychat/widgets/matrix.dart'; /// It shows a variety of progress indicators such as /// messages sent, words used, and error types, which can /// be clicked to access more fine-grained analytics data. -class LearningProgressIndicators extends StatefulWidget { +class LearningProgressIndicators extends StatelessWidget { final ProgressIndicatorEnum? selected; final bool canSelect; @@ -30,67 +28,6 @@ class LearningProgressIndicators extends StatefulWidget { this.canSelect = true, }); - @override - State createState() => - LearningProgressIndicatorsState(); -} - -class LearningProgressIndicatorsState - extends State { - ConstructListModel get _constructsModel => - MatrixState.pangeaController.getAnalytics.constructListModel; - bool _loading = true; - - StreamSubscription? _analyticsSubscription; - StreamSubscription? _languageSubscription; - - @override - void initState() { - super.initState(); - - // if getAnalytics has already finished initializing, - // the data is loaded and should be displayed. - if (MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted) { - updateData(); - } - _analyticsSubscription = MatrixState - .pangeaController.getAnalytics.analyticsStream.stream - .listen((_) => updateData()); - - // rebuild when target language changes - _languageSubscription = MatrixState - .pangeaController.userController.languageStream.stream - .listen((_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _analyticsSubscription?.cancel(); - _analyticsSubscription = null; - _languageSubscription?.cancel(); - _languageSubscription = null; - - super.dispose(); - } - - void updateData() { - if (_loading) _loading = false; - if (mounted) setState(() {}); - } - - int uniqueLemmas(ProgressIndicatorEnum indicator) { - switch (indicator) { - case ProgressIndicatorEnum.morphsUsed: - return _constructsModel.numConstructs(ConstructTypeEnum.morph); - case ProgressIndicatorEnum.wordsUsed: - return _constructsModel.numConstructs(ConstructTypeEnum.vocab); - default: - return 0; - } - } - @override Widget build(BuildContext context) { final client = Matrix.of(context).client; @@ -98,175 +35,202 @@ class LearningProgressIndicatorsState return const SizedBox(); } - final userL1 = MatrixState.pangeaController.userController.userL1; - final userL2 = MatrixState.pangeaController.userController.userL2; - final isColumnMode = FluffyThemes.isColumnMode(context); + final analyticsService = Matrix.of(context).analyticsDataService; - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Row( - spacing: isColumnMode ? 16.0 : 4.0, - children: [ - ...ConstructTypeEnum.values.map( - (c) => HoverButton( - selected: widget.selected == c.indicator, - onPressed: () { - context.go( - "/rooms/analytics/${c.string}", - ); - }, - child: ProgressIndicatorBadge( - indicator: c.indicator, - loading: _loading, - points: uniqueLemmas(c.indicator), - ), - ), - ), - HoverButton( - selected: widget.selected == - ProgressIndicatorEnum.activities, - onPressed: () { - context.go( - "/rooms/analytics/activities", - ); - }, - child: Tooltip( - message: ProgressIndicatorEnum.activities - .tooltip(context), + return StreamBuilder( + stream: MatrixState.pangeaController.userController.languageStream.stream, + builder: (context, _) { + final userL1 = MatrixState.pangeaController.userController.userL1; + final userL2 = MatrixState.pangeaController.userController.userL2; + + final analyticsRoom = Matrix.of(context).client.analyticsRoomLocal(); + final archivedActivitiesCount = + analyticsRoom?.archivedActivitiesCount ?? 0; + + return StreamBuilder( + stream: + analyticsService.updateDispatcher.constructUpdateStream.stream, + builder: (context, _) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), child: Row( - mainAxisSize: MainAxisSize.min, + spacing: isColumnMode ? 16.0 : 4.0, children: [ - Icon( - size: 18, - Icons.radar, - color: Theme.of(context).colorScheme.primary, - weight: 1000, + ...ConstructTypeEnum.values.map( + (c) => HoverButton( + selected: selected == c.indicator, + onPressed: () { + context.go( + "/rooms/analytics/${c.string}", + ); + }, + child: ProgressIndicatorBadge( + indicator: c.indicator, + loading: analyticsService.isInitializing, + points: analyticsService.numConstructs(c), + ), + ), ), - const SizedBox(width: 6.0), - AnimatedFloatingNumber( - number: MatrixState.pangeaController - .getAnalytics.archivedActivitiesCount, + HoverButton( + selected: selected == + ProgressIndicatorEnum.activities, + onPressed: () { + context.go( + "/rooms/analytics/activities", + ); + }, + child: Tooltip( + message: ProgressIndicatorEnum.activities + .tooltip(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 18, + Icons.radar, + color: Theme.of(context) + .colorScheme + .primary, + weight: 1000, + ), + const SizedBox(width: 6.0), + AnimatedFloatingNumber( + number: archivedActivitiesCount, + ), + ], + ), + ), ), ], ), ), - ), - ], - ), - ), - HoverButton( - onPressed: () => showDialog( - context: context, - builder: (c) => const SettingsLearning(), - barrierDismissible: false, - ), - child: Row( - children: [ - if (userL1 != null && userL2 != null) - Text( - userL1.langCodeShort.toUpperCase(), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + HoverButton( + onPressed: () => showDialog( + context: context, + builder: (c) => const SettingsLearning(), + barrierDismissible: false, + ), + child: Row( + children: [ + if (userL1 != null && userL2 != null) + Text( + userL1.langCodeShort.toUpperCase(), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + if (userL1 != null && userL2 != null) + const Icon(Icons.chevron_right_outlined), + if (userL2 != null) + Text( + userL2.langCodeShort.toUpperCase(), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + ], + ), ), - if (userL1 != null && userL2 != null) - const Icon(Icons.chevron_right_outlined), - if (userL2 != null) - Text( - userL2.langCodeShort.toUpperCase(), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 6), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: HoverBuilder( - builder: (context, hovered) { - return Container( - decoration: BoxDecoration( - color: hovered && widget.canSelect - ? Theme.of(context) - .colorScheme - .primary - .withAlpha((0.2 * 255).round()) - : Colors.transparent, - borderRadius: BorderRadius.circular(36.0), + ], ), - padding: const EdgeInsets.symmetric( - vertical: 2.0, - horizontal: 4.0, - ), - child: MouseRegion( - cursor: widget.canSelect - ? SystemMouseCursors.click - : MouseCursor.defer, - child: GestureDetector( - onTap: widget.canSelect - ? () { - context.go("/rooms/analytics/level"); - } - : null, - child: Row( - spacing: 8.0, - children: [ - Expanded( - child: LearningProgressBar( - level: _constructsModel.level, - totalXP: _constructsModel.totalXP, - height: 24.0, - loading: _loading, + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: HoverBuilder( + builder: (context, hovered) { + return Container( + decoration: BoxDecoration( + color: hovered && canSelect + ? Theme.of(context) + .colorScheme + .primary + .withAlpha((0.2 * 255).round()) + : Colors.transparent, + borderRadius: BorderRadius.circular(36.0), + ), + padding: const EdgeInsets.symmetric( + vertical: 2.0, + horizontal: 4.0, + ), + child: MouseRegion( + cursor: canSelect + ? SystemMouseCursors.click + : MouseCursor.defer, + child: GestureDetector( + onTap: canSelect + ? () { + context.go("/rooms/analytics/level"); + } + : null, + child: FutureBuilder( + future: analyticsService.derivedData, + builder: (context, snapshot) { + return Row( + spacing: 8.0, + children: [ + Expanded( + child: LearningProgressBar( + height: 24.0, + loading: !snapshot.hasData, + progress: snapshot + .data?.levelProgress ?? + 0.0, + ), + ), + if (snapshot.hasData) + Text( + "⭐ ${snapshot.data!.level}", + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context) + .colorScheme + .primary, + ), + ), + ], + ); + }, + ), ), ), - if (!_loading) - Text( - "⭐ ${_constructsModel.level}", - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - ], - ), + ); + }, ), ), - ); - }, + const SizedBox(height: 16.0), + ], + ), ), - ), - const SizedBox(height: 16.0), - ], - ), - ), - ], + ], + ); + }, + ); + }, ); } } diff --git a/lib/pangea/analytics_summary/level_analytics_details_content.dart b/lib/pangea/analytics_summary/level_analytics_details_content.dart new file mode 100644 index 000000000..e42917644 --- /dev/null +++ b/lib/pangea/analytics_summary/level_analytics_details_content.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.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/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LevelAnalyticsDetailsContent extends StatelessWidget { + const LevelAnalyticsDetailsContent({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final isColumnMode = FluffyThemes.isColumnMode(context); + final analyticsService = Matrix.of(context).analyticsDataService; + + return StreamBuilder( + stream: analyticsService.updateDispatcher.constructUpdateStream.stream, + builder: (context, _) { + return Scaffold( + appBar: AppBar( + titleSpacing: 0, + automaticallyImplyLeading: false, + title: FutureBuilder( + future: analyticsService.derivedData, + builder: (context, snapshot) { + final totalXP = snapshot.data?.totalXP ?? 0; + final maxLevelXP = snapshot.data?.minXPForNextLevel ?? 0; + final level = snapshot.data?.level ?? 0; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "⭐ ${L10n.of(context).levelShort(level)}", + style: TextStyle( + fontSize: isColumnMode ? 24 : 16, + fontWeight: FontWeight.w900, + color: AppConfig.gold, + ), + ), + Text( + L10n.of(context).xpIntoLevel(totalXP, maxLevelXP), + style: TextStyle( + fontSize: isColumnMode ? 24 : 16, + fontWeight: FontWeight.w900, + color: AppConfig.gold, + ), + ), + ], + ), + ); + }, + ), + ), + body: FutureBuilder( + future: analyticsService.getUses(count: 100), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + final uses = snapshot.data!; + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: uses.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.levelAnalytics, + padding: EdgeInsets.symmetric(vertical: 16.0), + ); + } + index--; + + final use = uses[index]; + String lemmaCopy = use.lemma; + if (use.constructType == ConstructTypeEnum.morph) { + lemmaCopy = getGrammarCopy( + category: use.category, + lemma: use.lemma, + context: context, + ) ?? + use.lemma; + } + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + width: 40, + alignment: Alignment.centerLeft, + child: Icon(use.useType.icon), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + "\"$lemmaCopy\" - ${use.useType.description(context)}", + style: const TextStyle(fontSize: 14), + ), + ), + Container( + alignment: Alignment.topRight, + width: 60, + child: Row( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${use.xp > 0 ? '+' : ''}${use.xp}", + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 14, + height: 1, + color: use.pointValueColor(context), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/pangea/analytics_summary/level_dialog_content.dart b/lib/pangea/analytics_summary/level_dialog_content.dart deleted file mode 100644 index f87255a31..000000000 --- a/lib/pangea/analytics_summary/level_dialog_content.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.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/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; -import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; -import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class LevelDialogContent extends StatelessWidget { - const LevelDialogContent({ - super.key, - }); - - GetAnalyticsController get analytics => - MatrixState.pangeaController.getAnalytics; - - int get level => analytics.constructListModel.level; - int get totalXP => analytics.constructListModel.totalXP; - int get maxLevelXP => analytics.minXPForNextLevel; - List get uses => analytics.constructListModel.truncatedUses; - - bool get _loading => - !MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted; - - @override - Widget build(BuildContext context) { - final isColumnMode = FluffyThemes.isColumnMode(context); - - return StreamBuilder( - stream: analytics.analyticsStream.stream, - builder: (context, _) { - if (_loading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - return Scaffold( - appBar: AppBar( - titleSpacing: 0, - automaticallyImplyLeading: false, - title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "⭐ ${L10n.of(context).levelShort(level)}", - style: TextStyle( - fontSize: isColumnMode ? 24 : 16, - fontWeight: FontWeight.w900, - color: AppConfig.gold, - ), - ), - Text( - L10n.of(context).xpIntoLevel(totalXP, maxLevelXP), - style: TextStyle( - fontSize: isColumnMode ? 24 : 16, - fontWeight: FontWeight.w900, - color: AppConfig.gold, - ), - ), - ], - ), - ), - ), - body: Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: uses.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.levelAnalytics, - padding: EdgeInsets.symmetric(vertical: 16.0), - ); - } - index--; - - final use = uses[index]; - String lemmaCopy = use.lemma; - if (use.constructType == ConstructTypeEnum.morph) { - lemmaCopy = getGrammarCopy( - category: use.category, - lemma: use.lemma, - context: context, - ) ?? - use.lemma; - } - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Container( - width: 40, - alignment: Alignment.centerLeft, - child: Icon(use.useType.icon), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - "\"$lemmaCopy\" - ${use.useType.description(context)}", - style: const TextStyle(fontSize: 14), - ), - ), - Container( - alignment: Alignment.topRight, - width: 60, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "${use.xp > 0 ? '+' : ''}${use.xp}", - style: TextStyle( - fontWeight: FontWeight.w900, - fontSize: 14, - height: 1, - color: use.pointValueColor(context), - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/pangea/analytics_summary/progress_indicators_enum.dart b/lib/pangea/analytics_summary/progress_indicators_enum.dart index b5b5dd10a..7e3cb30a2 100644 --- a/lib/pangea/analytics_summary/progress_indicators_enum.dart +++ b/lib/pangea/analytics_summary/progress_indicators_enum.dart @@ -25,9 +25,7 @@ enum ProgressIndicatorEnum { return null; } } -} -extension ProgressIndicatorsExtension on ProgressIndicatorEnum { IconData get icon { switch (this) { case ProgressIndicatorEnum.wordsUsed: diff --git a/lib/pangea/authentication/p_logout.dart b/lib/pangea/authentication/p_logout.dart index 626d5f05e..fdc2a25a8 100644 --- a/lib/pangea/authentication/p_logout.dart +++ b/lib/pangea/authentication/p_logout.dart @@ -31,8 +31,10 @@ void pLogoutAction( final client = Matrix.of(context).client; // before wiping out locally cached construct data, save it to the server - await MatrixState.pangeaController.putAnalytics - .sendLocalAnalyticsToAnalyticsRoom(onLogout: true); + await Matrix.of(context) + .analyticsDataService + .updateService + .sendLocalAnalyticsToAnalyticsRoom(); final redirect = client.onLoginStateChanged.stream .where((state) => state != LoginState.loggedIn) diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index d1276f444..3d80b2204 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -10,8 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; -import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart'; import 'package:fluffychat/pangea/common/utils/p_vguard.dart'; import 'package:fluffychat/pangea/languages/locale_provider.dart'; @@ -26,8 +25,6 @@ import '../utils/firebase_analytics.dart'; class PangeaController { ///pangeaControllers late UserController userController; - late GetAnalyticsController getAnalytics; - late PutAnalyticsController putAnalytics; late SubscriptionController subscriptionController; ///store Services @@ -35,14 +32,13 @@ class PangeaController { StreamSubscription? _languageSubscription; StreamSubscription? _settingsSubscription; + StreamSubscription? _joinSpaceSubscription; ///Matrix Variables final MatrixState matrixState; PangeaController({required this.matrixState}) { userController = UserController(); - getAnalytics = GetAnalyticsController(this); - putAnalytics = PutAnalyticsController(this); subscriptionController = SubscriptionController(this); PAuthGaurd.pController = this; _registerSubscriptions(); @@ -53,7 +49,7 @@ class PangeaController { /// because of order of execution does not matter, /// and running them at the same times speeds them up. void initControllers() { - _initAnalyticsControllers(); + _initAnalytics(); subscriptionController.initialize(); matrixState.client.setPangeaPushRules(); TtsController.setAvailableLanguages(); @@ -71,12 +67,13 @@ class PangeaController { } void _onLogout(BuildContext context) { - _disposeAnalyticsControllers(); userController.clear(); _languageSubscription?.cancel(); _settingsSubscription?.cancel(); + _joinSpaceSubscription?.cancel(); _languageSubscription = null; _settingsSubscription = null; + _joinSpaceSubscription = null; GoogleAnalytics.logout(); _clearCache(); @@ -109,11 +106,6 @@ class PangeaController { GoogleAnalytics.analyticsUserUpdate(userID); } - void _disposeAnalyticsControllers() { - putAnalytics.dispose(); - getAnalytics.dispose(); - } - void _registerSubscriptions() { _languageSubscription?.cancel(); _languageSubscription = @@ -122,6 +114,11 @@ class PangeaController { _settingsSubscription?.cancel(); _settingsSubscription = userController.settingsUpdateStream.stream .listen((_) => matrixState.client.updateBotOptions()); + + _joinSpaceSubscription?.cancel(); + _joinSpaceSubscription ??= matrixState.client.onSync.stream + .where(matrixState.client.isJoinSpaceSyncUpdate) + .listen((_) => matrixState.client.addAnalyticsRoomsToSpaces()); } Future _clearCache({List exclude = const []}) async { @@ -146,19 +143,19 @@ class PangeaController { await Future.wait(futures); } - Future _initAnalyticsControllers() async { - putAnalytics.initialize(); - await getAnalytics.initialize(); + Future _initAnalytics() async { + await GetStorage.init("activity_analytics_storage"); + + matrixState.client.updateAnalyticsRoomJoinRules(); + matrixState.client.addAnalyticsRoomsToSpaces(); } Future resetAnalytics() async { - _disposeAnalyticsControllers(); - await _initAnalyticsControllers(); + await _initAnalytics(); } Future _onLanguageUpdate(LanguageUpdate update) async { final exclude = [ - 'analytics_storage', 'course_location_media_storage', 'course_location_storage', 'course_media_storage', @@ -184,7 +181,6 @@ class PangeaController { 'objective_list_storage', 'topic_list_storage', 'activity_plan_search_storage', - "analytics_storage", "version_storage", 'lemma_storage', 'svg_cache', diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index 50dcf1dca..f5c1716ca 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -4,13 +4,13 @@ import 'package:flutter/foundation.dart'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart' hide Result; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_constants.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; @@ -139,18 +139,6 @@ class ConstructIdentifier { bool get isContentWord => PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false; - ConstructUses get constructUses => - MatrixState.pangeaController.getAnalytics.constructListModel - .getConstructUses( - this, - ) ?? - ConstructUses( - lemma: lemma, - constructType: ConstructTypeEnum.morph, - category: category, - uses: [], - ); - LemmaInfoRequest lemmaInfoRequest(Map messageInfo) => LemmaInfoRequest( partOfSpeech: category, @@ -173,43 +161,33 @@ class ConstructIdentifier { lemmaInfoRequest(messageInfo), ); - List get userSetEmoji => userLemmaInfo.emojis ?? []; + String? get userSetEmoji => _userLemmaInfo.emojis?.firstOrNull; - UserSetLemmaInfo get userLemmaInfo { - switch (type) { - case ConstructTypeEnum.vocab: - return MatrixState.pangeaController.matrixState.client - .analyticsRoomLocal() - ?.getUserSetLemmaInfo(this) ?? - UserSetLemmaInfo(); - case ConstructTypeEnum.morph: - debugger(when: kDebugMode); - ErrorHandler.logError( - e: Exception("Morphs should not have userSetEmoji"), - data: toJson(), - ); - return UserSetLemmaInfo(); - } - } + UserSetLemmaInfo get _userLemmaInfo => + MatrixState.pangeaController.matrixState.client + .analyticsRoomLocal() + ?.getUserSetLemmaInfo(this) ?? + UserSetLemmaInfo(); - Future setUserLemmaInfo(UserSetLemmaInfo newLemmaInfo) async { - final client = MatrixState.pangeaController.matrixState.client; - final l2 = MatrixState.pangeaController.userController.userL2; - if (l2 == null) return; + String get storageKey => TupleKey(lemma, type.name, category).toString(); - final analyticsRoom = await client.getMyAnalyticsRoom(l2); - if (analyticsRoom == null) return; - if (userLemmaInfo == newLemmaInfo) return; + String get compositeKey => '$lemma|${type.name}'.toLowerCase(); - try { - await analyticsRoom.setUserSetLemmaInfo(this, newLemmaInfo); - } catch (err, s) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: err, - data: newLemmaInfo.toJson(), - s: s, - ); - } + static ConstructIdentifier fromStorageKey(String key) { + final parts = key.split('|'); + final lemma = parts[0]; + final typeName = parts[1]; + final category = parts[2]; + + final type = ConstructTypeEnum.values.firstWhereOrNull( + (e) => e.name == typeName, + ) ?? + ConstructTypeEnum.vocab; + + return ConstructIdentifier( + lemma: lemma, + type: type, + category: category, + ); } } diff --git a/lib/pangea/events/models/pangea_token_model.dart b/lib/pangea/events/models/pangea_token_model.dart index 05387e842..d15004ee2 100644 --- a/lib/pangea/events/models/pangea_token_model.dart +++ b/lib/pangea/events/models/pangea_token_model.dart @@ -1,11 +1,6 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; @@ -15,7 +10,6 @@ import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_repo.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_morph_choice.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import '../../common/constants/model_keys.dart'; import '../../lemmas/lemma.dart'; @@ -175,18 +169,6 @@ class PangeaToken { return null; } - ConstructUses get vocabConstruct => - MatrixState.pangeaController.getAnalytics.constructListModel - .getConstructUses( - vocabConstructID, - ) ?? - ConstructUses( - lemma: lemma.text, - constructType: ConstructTypeEnum.vocab, - category: pos, - uses: [], - ); - ConstructIdentifier? morphIdByFeature(MorphFeaturesEnum feature) { final tag = getMorphTag(feature); if (tag == null) return null; @@ -197,40 +179,6 @@ class PangeaToken { ); } - /// lastUsed by activity type, construct and form - DateTime? _lastUsedByActivityType( - ActivityTypeEnum a, - MorphFeaturesEnum? feature, - ) { - if (a == ActivityTypeEnum.morphId && feature == null) { - debugger(when: kDebugMode); - return null; - } - final ConstructIdentifier? cId = a == ActivityTypeEnum.morphId - ? morphIdByFeature(feature!) - : vocabConstructID; - - if (cId == null) return null; - - final correctUseTimestamps = cId.constructUses.uses - .where((u) => u.form == text.content) - .map((u) => u.timeStamp) - .toList(); - - if (correctUseTimestamps.isEmpty) return null; - - // return the most recent timestamp - return correctUseTimestamps.reduce((a, b) => a.isAfter(b) ? a : b); - } - - /// daysSinceLastUse by activity type - /// returns 1000 if there is no last use - int daysSinceLastUseByType(ActivityTypeEnum a, MorphFeaturesEnum? feature) { - final lastUsed = _lastUsedByActivityType(a, feature); - if (lastUsed == null) return 20; - return DateTime.now().difference(lastUsed).inDays; - } - ConstructIdentifier get vocabConstructID => ConstructIdentifier( lemma: lemma.text, type: ConstructTypeEnum.vocab, @@ -268,15 +216,6 @@ class PangeaToken { ); }).toList(); - /// [0,infinity) - a higher number means higher priority - int activityPriorityScore( - ActivityTypeEnum a, - MorphFeaturesEnum? morphFeature, - ) { - return daysSinceLastUseByType(a, morphFeature) * - (vocabConstructID.isContentWord ? 10 : 9); - } - bool eligibleForPractice(ActivityTypeEnum activityType) { switch (activityType) { case ActivityTypeEnum.emoji: diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index 73a07cbaf..7fe4e8e1e 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -7,7 +7,6 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:html_unescape/html_unescape.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/markdown.dart'; @@ -25,12 +24,10 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/extensions/join_rule_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; diff --git a/lib/pangea/extensions/room_events_extension.dart b/lib/pangea/extensions/room_events_extension.dart index fd19ba199..2d689047f 100644 --- a/lib/pangea/extensions/room_events_extension.dart +++ b/lib/pangea/extensions/room_events_extension.dart @@ -297,46 +297,59 @@ extension EventsRoomExtension on Room { Future> getRoomAnalyticsEvents({ String? userID, int? count, + DateTime? since, }) async { userID ??= client.userID; if (userID == null) return []; - GetRoomEventsResponse resp = await client.getRoomEvents( - id, - Direction.b, - limit: count ?? 100, - filter: jsonEncode( - StateFilter( + + final timeline = await getTimeline(); + + int numSearches = 0; + while (numSearches < 10 && timeline.canRequestFuture) { + await timeline.requestFuture( + historyCount: 100, + filter: StateFilter( types: [ PangeaEventTypes.construct, ], senders: [userID], ), - ), - ); - - int numSearches = 0; - while (numSearches < 10 && resp.end != null) { - if (count != null && resp.chunk.length <= count) break; - final nextResp = await client.getRoomEvents( - id, - Direction.b, - limit: count ?? 100, - filter: jsonEncode( - StateFilter( - types: [ - PangeaEventTypes.construct, - ], - senders: [userID], - ), - ), - from: resp.end, ); - nextResp.chunk.addAll(resp.chunk); - resp = nextResp; numSearches += 1; } - return resp.chunk.map((e) => Event.fromMatrixEvent(e, this)).toList(); + while (numSearches < 10 && + timeline.canRequestHistory && + (count == null || timeline.events.length < count) && + (since == null || + timeline.chunk.events.first.originServerTs.isAfter(since))) { + await timeline.requestHistory( + historyCount: 100, + filter: StateFilter( + types: [ + PangeaEventTypes.construct, + ], + senders: [userID], + ), + ); + numSearches += 1; + } + + final events = timeline.chunk.events + .where( + (e) => e.type == PangeaEventTypes.construct && e.senderId == userID, + ) + .map((e) => Event.fromMatrixEvent(e, this)); + + if (count != null) { + return events.take(count).toList(); + } + + if (since != null) { + return events.where((e) => e.originServerTs.isAfter(since)).toList(); + } + + return events.toList(); } Future> getAllEvents({String? since}) async { diff --git a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart index 58093312c..c9e63a6c9 100644 --- a/lib/pangea/lemmas/lemma_highlight_emoji_row.dart +++ b/lib/pangea/lemmas/lemma_highlight_emoji_row.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; -import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart'; @@ -37,29 +36,8 @@ class LemmaHighlightEmojiRow extends StatefulWidget { State createState() => LemmaHighlightEmojiRowState(); } -class LemmaHighlightEmojiRowState extends State { - late StreamSubscription _analyticsSubscription; - - @override - void initState() { - super.initState(); - _analyticsSubscription = MatrixState - .pangeaController.getAnalytics.analyticsStream.stream - .listen(_onAnalyticsUpdate); - } - - @override - void dispose() { - _analyticsSubscription.cancel(); - super.dispose(); - } - - void _onAnalyticsUpdate(AnalyticsStreamUpdate update) { - if (update.targetID != null) { - OverlayUtil.showPointsGained(update.targetID!, update.points, context); - } - } - +class LemmaHighlightEmojiRowState extends State + with AnalyticsUpdater { @override Widget build(BuildContext context) { return LemmaMeaningBuilder( @@ -176,9 +154,6 @@ class EmojiChoiceItemState extends State { }); } - LayerLink get layerLink => - MatrixState.pAnyState.layerLinkAndKey(widget.transformTargetId).link; - @override Widget build(BuildContext context) { return HoverBuilder( @@ -192,8 +167,13 @@ class EmojiChoiceItemState extends State { ? Colors.white : Theme.of(context).colorScheme.primary, child: CompositedTransformTarget( - link: layerLink, + link: MatrixState.pAnyState + .layerLinkAndKey(widget.transformTargetId) + .link, child: AnimatedContainer( + key: MatrixState.pAnyState + .layerLinkAndKey(widget.transformTargetId) + .key, duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(10), decoration: BoxDecoration( diff --git a/lib/pangea/lemmas/user_set_lemma_info.dart b/lib/pangea/lemmas/user_set_lemma_info.dart index 1931f6b48..33d1ed6ca 100644 --- a/lib/pangea/lemmas/user_set_lemma_info.dart +++ b/lib/pangea/lemmas/user_set_lemma_info.dart @@ -42,5 +42,6 @@ class UserSetLemmaInfo { meaning == other.meaning; @override - int get hashCode => meaning.hashCode ^ Object.hashAll(emojis ?? []); + int get hashCode => + meaning.hashCode ^ const ListEquality().hash(emojis ?? []); } diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 88607e05d..2bb790379 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -29,11 +29,10 @@ class EmojiActivityGenerator { final List usedEmojis = []; for (final token in req.targetTokens) { - final List userSavedEmojis = token.vocabConstructID.userSetEmoji; - if (userSavedEmojis.isNotEmpty && - !usedEmojis.contains(userSavedEmojis.first)) { - matchInfo[token.vocabForm] = [userSavedEmojis.first]; - usedEmojis.add(userSavedEmojis.first); + final userSavedEmoji = token.vocabConstructID.userSetEmoji; + if (userSavedEmoji != null && !usedEmojis.contains(userSavedEmoji)) { + matchInfo[token.vocabForm] = [userSavedEmoji]; + usedEmojis.add(userSavedEmoji); } else { missingEmojis.add(token); } diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index e7a1ab7bc..f805260e5 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -36,12 +36,12 @@ class LemmaActivityGenerator { static Future> _lemmaActivityDistractors( PangeaToken token, ) async { - final List lemmas = MatrixState - .pangeaController.getAnalytics.constructListModel - .constructList(type: ConstructTypeEnum.vocab) - .map((c) => c.lemma) - .toSet() - .toList(); + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab); + + final List lemmas = + constructs.values.map((c) => c.lemma).toSet().toList(); // Offload computation to an isolate final Map distances = diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index 8d6c45de6..babba161a 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:get_storage/get_storage.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -34,11 +35,11 @@ class _PracticeSelectionCacheEntry { class PracticeSelectionRepo { static final GetStorage _storage = GetStorage('practice_selection_cache'); - static PracticeSelection? get( + static Future get( String eventId, String messageLanguage, List tokens, - ) { + ) async { final userL2 = MatrixState.pangeaController.userController.userL2; if (userL2?.langCodeShort != messageLanguage.split("-").first) { return null; @@ -47,7 +48,7 @@ class PracticeSelectionRepo { final cached = _getCached(eventId); if (cached != null) return cached; - final newEntry = _fetch( + final newEntry = await _fetch( tokens: tokens, langCode: messageLanguage, ); @@ -56,10 +57,10 @@ class PracticeSelectionRepo { return newEntry; } - static PracticeSelection _fetch({ + static Future _fetch({ required List tokens, required String langCode, - }) { + }) async { if (langCode.split("-")[0] != MatrixState.pangeaController.userController.userL2?.langCodeShort) { return PracticeSelection({}); @@ -69,7 +70,7 @@ class PracticeSelectionRepo { if (eligibleTokens.isEmpty) { return PracticeSelection({}); } - final queue = _fillActivityQueue(eligibleTokens); + final queue = await _fillActivityQueue(eligibleTokens); final selection = PracticeSelection(queue); return selection; } @@ -116,12 +117,12 @@ class PracticeSelectionRepo { _storage.write(eventId, cachedEntry.toJson()); } - static Map> _fillActivityQueue( + static Future>> _fillActivityQueue( List tokens, - ) { + ) async { final queue = >{}; for (final type in ActivityTypeEnum.practiceTypes) { - queue[type] = _buildActivity(type, tokens); + queue[type] = await _buildActivity(type, tokens); } return queue; } @@ -129,26 +130,18 @@ class PracticeSelectionRepo { static int _sortTokens( PangeaToken a, PangeaToken b, - ActivityTypeEnum activityType, - ) { - final bScore = b.activityPriorityScore(activityType, null); - final aScore = a.activityPriorityScore(activityType, null); - return bScore.compareTo(aScore); - } + int aScore, + int bScore, + ) => + bScore.compareTo(aScore); - static int _sortMorphTargets(PracticeTarget a, PracticeTarget b) { - final bScore = b.tokens.first.activityPriorityScore( - ActivityTypeEnum.morphId, - b.morphFeature!, - ); - - final aScore = a.tokens.first.activityPriorityScore( - ActivityTypeEnum.morphId, - a.morphFeature!, - ); - - return bScore.compareTo(aScore); - } + static int _sortMorphTargets( + PracticeTarget a, + PracticeTarget b, + int aScore, + int bScore, + ) => + bScore.compareTo(aScore); static List _tokenToMorphTargets(PangeaToken t) { return t.morphsBasicallyEligibleForPracticeByPriority @@ -162,10 +155,10 @@ class PracticeSelectionRepo { .toList(); } - static List _buildActivity( + static Future> _buildActivity( ActivityTypeEnum activityType, List tokens, - ) { + ) async { if (activityType == ActivityTypeEnum.morphId) { return _buildMorphActivity(tokens); } @@ -184,7 +177,12 @@ class PracticeSelectionRepo { return []; } - practiceTokens.sort((a, b) => _sortTokens(a, b, activityType)); + final scores = await _fetchPriorityScores( + practiceTokens, + activityType, + ); + + practiceTokens.sort((a, b) => _sortTokens(a, b, scores[a]!, scores[b]!)); practiceTokens = practiceTokens.take(8).toList(); practiceTokens.shuffle(); @@ -196,10 +194,23 @@ class PracticeSelectionRepo { ]; } - static List _buildMorphActivity(List tokens) { + static Future> _buildMorphActivity( + List tokens, + ) async { final List practiceTokens = List.from(tokens); final candidates = practiceTokens.expand(_tokenToMorphTargets).toList(); - candidates.sort(_sortMorphTargets); + final scores = await _fetchPriorityScores( + practiceTokens, + ActivityTypeEnum.morphId, + ); + candidates.sort( + (a, b) => _sortMorphTargets( + a, + b, + scores[a.tokens.first]!, + scores[b.tokens.first]!, + ), + ); final seenTexts = {}; final seenLemmas = {}; @@ -210,4 +221,38 @@ class PracticeSelectionRepo { ); return candidates.take(PracticeSelection.maxQueueLength).toList(); } + + static Future> _fetchPriorityScores( + List tokens, + ActivityTypeEnum activityType, + ) async { + final scores = {}; + for (final token in tokens) { + scores[token] = 0; + } + + final ids = tokens.map((t) => t.vocabConstructID).toList(); + final idMap = { + for (final token in tokens) token: token.vocabConstructID, + }; + + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getConstructUses(ids); + + for (final token in tokens) { + final construct = constructs[idMap[token]]; + final lastUsed = construct?.uses.firstWhereOrNull( + (u) => activityType.associatedUseTypes.contains(u.useType), + ); + + final daysSinceLastUsed = lastUsed == null + ? 20 + : DateTime.now().difference(lastUsed.timeStamp).inDays; + + scores[token] = + daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 9); + } + return scores; + } } diff --git a/lib/pangea/space_analytics/download_space_analytics_dialog.dart b/lib/pangea/space_analytics/download_space_analytics_dialog.dart index fa9a31afe..8fe58bc0d 100644 --- a/lib/pangea/space_analytics/download_space_analytics_dialog.dart +++ b/lib/pangea/space_analytics/download_space_analytics_dialog.dart @@ -9,9 +9,9 @@ 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/analytics_misc/saved_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/download/download_file_util.dart'; @@ -170,19 +170,11 @@ class DownloadAnalyticsDialogState extends State { 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( + summary = SpaceAnalyticsSummaryModel.fromEvents( userID, - constructs, - 0, - getCopy, - context, + constructEvents, + analyticsRoom.blockedConstructs, + analyticsRoom.archivedActivitiesCount, ); if (mounted) setState(() => _downloadStatuses[userID] = 2); } catch (e, s) { diff --git a/lib/pangea/space_analytics/space_analytics.dart b/lib/pangea/space_analytics/space_analytics.dart index d131d070c..de7b7c3b0 100644 --- a/lib/pangea/space_analytics/space_analytics.dart +++ b/lib/pangea/space_analytics/space_analytics.dart @@ -6,13 +6,12 @@ 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/analytics_misc/saved_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/languages/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'; @@ -23,88 +22,6 @@ import 'package:fluffychat/pangea/user/analytics_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}); @@ -345,24 +262,11 @@ class SpaceAnalyticsState extends State { 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( + summary = SpaceAnalyticsSummaryModel.fromEvents( userID, - constructs, - analyticsRoom.activityRoomIds.length, - (use) => - getGrammarCopy( - category: use.category, - lemma: use.lemma, - context: context, - ) ?? - use.lemma, - context, + constructEvents, + analyticsRoom.blockedConstructs, + analyticsRoom.archivedActivitiesCount, ); downloads[user] = AnalyticsDownload( diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 4abf261fc..bd62a0288 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -24,7 +24,9 @@ import 'package:fluffychat/widgets/matrix.dart'; class PracticeController with ChangeNotifier { final PangeaMessageEvent pangeaMessageEvent; - PracticeController(this.pangeaMessageEvent); + PracticeController(this.pangeaMessageEvent) { + _fetchPracticeSelection(); + } PracticeActivityModel? _activity; @@ -35,14 +37,7 @@ class PracticeController with ChangeNotifier { PracticeActivityModel? get activity => _activity; - PracticeSelection? get practiceSelection => - pangeaMessageEvent.messageDisplayRepresentation?.tokens != null - ? PracticeSelectionRepo.get( - pangeaMessageEvent.eventId, - pangeaMessageEvent.messageDisplayLangCode, - pangeaMessageEvent.messageDisplayRepresentation!.tokens!, - ) - : null; + PracticeSelection? practiceSelection; bool get isTotallyDone => isPracticeActivityDone(ActivityTypeEnum.emoji) && @@ -58,7 +53,7 @@ class PracticeController with ChangeNotifier { final target = practiceTargetForToken(token); if (MessagePracticeMode.wordEmoji == practiceMode) { - if (token.vocabConstructID.userSetEmoji.firstOrNull != null) { + if (token.vocabConstructID.userSetEmoji != null) { return false; } // Keep open even when completed to show emoji @@ -90,6 +85,15 @@ class PracticeController with ChangeNotifier { !_activity!.practiceTarget.hasAnyCorrectChoices; } + Future _fetchPracticeSelection() async { + if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return; + practiceSelection = await PracticeSelectionRepo.get( + pangeaMessageEvent.eventId, + pangeaMessageEvent.messageDisplayLangCode, + pangeaMessageEvent.messageDisplayRepresentation!.tokens!, + ); + } + Future> fetchActivityModel( PracticeTarget target, ) async { @@ -158,6 +162,9 @@ class PracticeController with ChangeNotifier { final targetId = "message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}"; + final updateService = MatrixState + .pangeaController.matrixState.analyticsDataService.updateService; + // we don't take off points for incorrect emoji matches if (_activity!.activityType != ActivityTypeEnum.emoji || isCorrect) { final constructUseType = _activity!.practiceTarget.record.responses.last @@ -180,28 +187,24 @@ class PracticeController with ChangeNotifier { ), ]; - MatrixState.pangeaController.putAnalytics.addAnalytics( + updateService.addAnalytics( + targetId, constructs, - eventId: pangeaMessageEvent.eventId, - roomId: pangeaMessageEvent.room.id, - targetId: targetId, ); } if (isCorrect) { if (_activity!.activityType == ActivityTypeEnum.emoji) { - choice.form.cId.setUserLemmaInfo( - choice.form.cId.userLemmaInfo.copyWith( - emojis: [choice.choiceContent], - ), + updateService.setLemmaInfo( + choice.form.cId, + emoji: choice.choiceContent, ); } if (_activity!.activityType == ActivityTypeEnum.wordMeaning) { - choice.form.cId.setUserLemmaInfo( - choice.form.cId.userLemmaInfo.copyWith( - meaning: choice.choiceContent, - ), + updateService.setLemmaInfo( + choice.form.cId, + meaning: choice.choiceContent, ); } } diff --git a/lib/pangea/toolbar/message_practice/token_practice_button.dart b/lib/pangea/toolbar/message_practice/token_practice_button.dart index c791c08f1..14e466b7d 100644 --- a/lib/pangea/toolbar/message_practice/token_practice_button.dart +++ b/lib/pangea/toolbar/message_practice/token_practice_button.dart @@ -263,7 +263,7 @@ class _NoActivityContentButton extends StatelessWidget { (res) => res.cId == token.vocabConstructID && res.isCorrect, ) ?.text ?? - token.vocabConstructID.userSetEmoji.firstOrNull ?? + token.vocabConstructID.userSetEmoji ?? ''; return Text( displayEmoji, diff --git a/lib/pangea/toolbar/message_selection_overlay.dart b/lib/pangea/toolbar/message_selection_overlay.dart index 20a158ed3..165c795b4 100644 --- a/lib/pangea/toolbar/message_selection_overlay.dart +++ b/lib/pangea/toolbar/message_selection_overlay.dart @@ -10,6 +10,7 @@ import 'package:matrix/matrix.dart' hide Result; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; @@ -55,7 +56,7 @@ class MessageSelectionOverlay extends StatefulWidget { } class MessageOverlayController extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, AnalyticsUpdater { Event get event => widget._event; PangeaTokenText? _selectedSpan; @@ -234,12 +235,7 @@ class MessageOverlayController extends State xp: ConstructUseTypeEnum.click.pointValue, ), ]; - MatrixState.pangeaController.putAnalytics.addAnalytics( - constructs, - eventId: event.eventId, - roomId: event.room.id, - targetId: "word-zoom-card-${token.text.uniqueKey}", - ); + addAnalytics(constructs, "word-zoom-card-${token.text.uniqueKey}"); } } } diff --git a/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart b/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart index 7f84d8769..e0a45a003 100644 --- a/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart +++ b/lib/pangea/toolbar/reading_assistance/select_mode_controller.dart @@ -8,7 +8,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart'; @@ -89,8 +88,6 @@ class SelectModeController with LemmaEmojiSetter { _sttTranslationLoader = _STTTranslationLoader(messageEvent); ValueNotifier selectedMode = ValueNotifier(null); - ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier = - ValueNotifier<(ConstructIdentifier, String)?>(null); final StreamController contentChangedStream = StreamController.broadcast(); @@ -101,7 +98,6 @@ class SelectModeController with LemmaEmojiSetter { void dispose() { selectedMode.dispose(); - constructEmojiNotifier.dispose(); playTokenNotifier.dispose(); _transcriptLoader.dispose(); _translationLoader.dispose(); @@ -198,12 +194,6 @@ class SelectModeController with LemmaEmojiSetter { selectedMode.value = mode; } - void setTokenEmoji( - ConstructIdentifier constructId, - String emoji, - ) => - constructEmojiNotifier.value = (constructId, emoji); - void setPlayingToken(PangeaTokenText? token) => playTokenNotifier.value = (token, !playTokenNotifier.value.$2); diff --git a/lib/pangea/toolbar/reading_assistance/token_emoji_button.dart b/lib/pangea/toolbar/reading_assistance/token_emoji_button.dart index edff30134..f5f5cbc74 100644 --- a/lib/pangea/toolbar/reading_assistance/token_emoji_button.dart +++ b/lib/pangea/toolbar/reading_assistance/token_emoji_button.dart @@ -2,16 +2,15 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance/select_mode_buttons.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class TokenEmojiButton extends StatefulWidget { - final ValueNotifier selectModeNotifier; - final ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier; - final VoidCallback onTap; +class TokenEmojiButton extends StatelessWidget with LemmaEmojiSetter { + static const double _buttonSize = 24.0; + final ValueNotifier selectModeNotifier; + final VoidCallback onTap; final PangeaToken? token; final String? targetId; final bool enabled; @@ -20,7 +19,6 @@ class TokenEmojiButton extends StatefulWidget { const TokenEmojiButton({ super.key, required this.selectModeNotifier, - required this.constructEmojiNotifier, required this.onTap, required this.textColor, this.token, @@ -28,127 +26,94 @@ class TokenEmojiButton extends StatefulWidget { this.enabled = true, }); - @override - State createState() => TokenEmojiButtonState(); -} - -class TokenEmojiButtonState extends State - with TickerProviderStateMixin, LemmaEmojiSetter { - final double buttonSize = 24.0; - SelectMode? _prevMode; - AnimationController? _controller; - Animation? _sizeAnimation; - - String? _emoji; - - @override - void initState() { - super.initState(); - _emoji = widget.token?.vocabConstructID.userSetEmoji.firstOrNull; - - _initAnimation(); - _prevMode = widget.selectModeNotifier.value; - widget.selectModeNotifier.addListener(_onUpdateSelectMode); - widget.constructEmojiNotifier.addListener(_onUpdateEmoji); - } - - @override - void dispose() { - _controller?.dispose(); - widget.selectModeNotifier.removeListener(_onUpdateSelectMode); - widget.constructEmojiNotifier.removeListener(_onUpdateEmoji); - super.dispose(); - } - - void _initAnimation() { - if (MatrixState.pangeaController.subscriptionController.isSubscribed == - false) { - return; - } - - _controller = AnimationController( - vsync: this, - duration: FluffyThemes.animationDuration, - ); - - _sizeAnimation = Tween( - begin: 0, - end: buttonSize, - ).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut)); - } - - void _onUpdateSelectMode() { - final mode = widget.selectModeNotifier.value; - if (_prevMode != SelectMode.emoji && mode == SelectMode.emoji) { - _controller?.forward(); - } else if (_prevMode == SelectMode.emoji && mode != SelectMode.emoji) { - _controller?.reverse(); - } - _prevMode = mode; - } - - void _onUpdateEmoji() { - final value = widget.constructEmojiNotifier.value; - if (value == null) return; - - final constructId = value.$1; - final emoji = value.$2; - - if (mounted && constructId == widget.token?.vocabConstructID) { - setState(() => _emoji = emoji); - } - } + bool get _canShow => + MatrixState.pangeaController.subscriptionController.isSubscribed != false; @override Widget build(BuildContext context) { - if (_sizeAnimation == null) { - return const SizedBox.shrink(); - } + if (!_canShow) return const SizedBox.shrink(); - final child = widget.enabled - ? Text( - _emoji ?? "-", - style: TextStyle(fontSize: buttonSize - 8.0).copyWith( - color: widget.textColor, - ), - textScaler: TextScaler.noScaling, - ) - : null; + Widget content = ValueListenableBuilder( + valueListenable: selectModeNotifier, + builder: (context, mode, _) { + final visible = mode == SelectMode.emoji; - final content = ValueListenableBuilder( - valueListenable: widget.selectModeNotifier, - builder: (context, mode, __) { - return mode == SelectMode.emoji - ? AnimatedBuilder( - key: widget.targetId != null - ? MatrixState.pAnyState - .layerLinkAndKey(widget.targetId!) - .key - : null, - animation: _sizeAnimation!, - child: child, - builder: (context, child) { - return InkWell( - onTap: widget.onTap, - borderRadius: BorderRadius.circular(99.0), - child: Container( - height: _sizeAnimation!.value, - width: widget.enabled ? _sizeAnimation!.value : 0, - alignment: Alignment.center, - child: child, + return AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: Curves.easeOut, + alignment: Alignment.center, + child: visible + ? InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(99), + child: SizedBox( + width: _buttonSize, + height: _buttonSize, + child: Center( + child: _EmojiText( + token: token, + enabled: enabled, + textColor: textColor, + fontSize: _buttonSize - 8, + ), ), - ); - }, - ) - : const SizedBox(); + ), + ) + : const SizedBox.shrink(), + ); }, ); - return widget.targetId != null - ? CompositedTransformTarget( - link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link, - child: content, - ) - : content; + if (targetId != null) { + final layer = MatrixState.pAnyState.layerLinkAndKey(targetId!); + content = CompositedTransformTarget( + link: layer.link, + child: KeyedSubtree( + key: layer.key, + child: content, + ), + ); + } + + return content; + } +} + +class _EmojiText extends StatelessWidget { + final PangeaToken? token; + final bool enabled; + final Color textColor; + final double fontSize; + + const _EmojiText({ + required this.token, + required this.enabled, + required this.textColor, + required this.fontSize, + }); + + @override + Widget build(BuildContext context) { + if (!enabled || token == null) return const SizedBox.shrink(); + + return StreamBuilder( + stream: Matrix.of(context) + .analyticsDataService + .updateDispatcher + .lemmaUpdateStream(token!.vocabConstructID), + builder: (context, snapshot) { + final emoji = snapshot.data?.emojis?.firstOrNull ?? + token!.vocabConstructID.userSetEmoji; + + return Text( + emoji ?? "-", + style: TextStyle( + fontSize: fontSize, + color: textColor, + ), + textScaler: TextScaler.noScaling, + ); + }, + ); } } diff --git a/lib/pangea/toolbar/reading_assistance/tokens_util.dart b/lib/pangea/toolbar/reading_assistance/tokens_util.dart index 6061eb3a5..134b101d7 100644 --- a/lib/pangea/toolbar/reading_assistance/tokens_util.dart +++ b/lib/pangea/toolbar/reading_assistance/tokens_util.dart @@ -98,11 +98,17 @@ class TokensUtil { } final List newTokens = []; + final analyticsService = + MatrixState.pangeaController.matrixState.analyticsDataService; for (final token in tokens) { if (!token.lemma.saveVocab || !token.vocabConstructID.isContentWord) { continue; } - if (token.vocabConstruct.uses.isNotEmpty) continue; + + if (analyticsService.hasUsedConstruct(token.vocabConstructID)) { + continue; + } + if (newTokens.any((t) => t == token.text)) continue; newTokens.add(token.text); diff --git a/lib/pangea/toolbar/word_card/lemma_reaction_picker.dart b/lib/pangea/toolbar/word_card/lemma_reaction_picker.dart index 047f3b5ef..cc4071c9b 100644 --- a/lib/pangea/toolbar/word_card/lemma_reaction_picker.dart +++ b/lib/pangea/toolbar/word_card/lemma_reaction_picker.dart @@ -9,54 +9,22 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class LemmaReactionPicker extends StatefulWidget { +class LemmaReactionPicker extends StatelessWidget with LemmaEmojiSetter { final Event? event; final ConstructIdentifier constructId; - final Function(String)? onSetEmoji; final String langCode; const LemmaReactionPicker({ super.key, required this.constructId, - required this.onSetEmoji, required this.langCode, this.event, }); - @override - State createState() => LemmaReactionPickerState(); -} - -class LemmaReactionPickerState extends State - with LemmaEmojiSetter { - String? _selectedEmoji; - - @override - void initState() { - super.initState(); - _setInitialEmoji(); - } - - @override - void didUpdateWidget(covariant LemmaReactionPicker oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.constructId != widget.constructId) { - _setInitialEmoji(); - } - } - - void _setInitialEmoji() { - setState( - () { - _selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull; - }, - ); - } - - Event? _sentReaction(String emoji) { - final userSentEmojis = widget.event! + Event? _sentReaction(String emoji, BuildContext context) { + final userSentEmojis = event! .aggregatedEvents( - widget.event!.room.timeline!, + event!.room.timeline!, RelationshipTypes.reaction, ) .where( @@ -68,33 +36,27 @@ class LemmaReactionPickerState extends State ); } - Future _setEmoji(String emoji) async { - setState(() => _selectedEmoji = emoji); - widget.onSetEmoji?.call(emoji); - + Future _setEmoji(String emoji, BuildContext context) async { await setLemmaEmoji( - widget.constructId, + constructId, emoji, - "emoji-choice-item-$emoji-${widget.constructId.lemma}", + "emoji-choice-item-$emoji-${constructId.lemma}", ); - - if (mounted) { - showLemmaEmojiSnackbar(context, widget.constructId, emoji); - } + showLemmaEmojiSnackbar(context, constructId, emoji); } - Future _sendOrRedactReaction(String emoji) async { - if (widget.event?.room.timeline == null) return; + Future _sendOrRedactReaction(String emoji, BuildContext context) async { + if (event?.room.timeline == null) return; try { - final reactionEvent = _sentReaction(emoji); + final reactionEvent = _sentReaction(emoji, context); if (reactionEvent != null) { await reactionEvent.redactEvent(); return; } - await widget.event!.room.sendReaction( - widget.event!.eventId, + await event!.room.sendReaction( + event!.eventId, emoji, ); } catch (e, s) { @@ -103,7 +65,7 @@ class LemmaReactionPickerState extends State s: s, data: { 'emoji': emoji, - 'eventId': widget.event?.eventId, + 'eventId': event?.eventId, }, ); } @@ -111,22 +73,35 @@ class LemmaReactionPickerState extends State @override Widget build(BuildContext context) { - return LemmaHighlightEmojiRow( - cId: widget.constructId, - langCode: widget.langCode, - onEmojiSelected: (emoji) => emoji != _selectedEmoji - ? _setEmoji(emoji) - : _sendOrRedactReaction(emoji), - emoji: _selectedEmoji, - messageInfo: widget.event?.content ?? {}, - selectedEmojiBadge: widget.event != null && - _selectedEmoji != null && - _sentReaction(_selectedEmoji!) == null - ? const Icon( - Icons.add_reaction, - size: 12.0, - ) - : null, + final stream = Matrix.of(context) + .analyticsDataService + .updateDispatcher + .lemmaUpdateStream(constructId); + + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + final selectedEmoji = + snapshot.data?.emojis?.firstOrNull ?? constructId.userSetEmoji; + + return LemmaHighlightEmojiRow( + cId: constructId, + langCode: langCode, + onEmojiSelected: (emoji) => emoji != selectedEmoji + ? _setEmoji(emoji, context) + : _sendOrRedactReaction(emoji, context), + emoji: selectedEmoji, + messageInfo: event?.content ?? {}, + selectedEmojiBadge: event != null && + selectedEmoji != null && + _sentReaction(selectedEmoji, context) == null + ? const Icon( + Icons.add_reaction, + size: 12.0, + ) + : null, + ); + }, ); } } diff --git a/lib/pangea/toolbar/word_card/reading_assistance_content.dart b/lib/pangea/toolbar/word_card/reading_assistance_content.dart index 83dca55fb..71d2e0d97 100644 --- a/lib/pangea/toolbar/word_card/reading_assistance_content.dart +++ b/lib/pangea/toolbar/word_card/reading_assistance_content.dart @@ -51,10 +51,6 @@ class ReadingAssistanceContent extends StatelessWidget { onClose: () => overlayController.updateSelectedSpan(null), langCode: overlayController.pangeaMessageEvent.messageDisplayLangCode, onDismissNewWordOverlay: () => overlayController.setState(() {}), - setEmoji: (emoji) => overlayController.selectModeController.setTokenEmoji( - overlayController.selectedToken!.vocabConstructID, - emoji, - ), onFlagTokenInfo: (LemmaInfoResponse lemmaInfo, String phonetics) { if (selectedTokenIndex < 0) return; final requestData = TokenInfoFeedbackRequestData( diff --git a/lib/pangea/toolbar/word_card/word_zoom_widget.dart b/lib/pangea/toolbar/word_card/word_zoom_widget.dart index 40930ae1c..161799b9e 100644 --- a/lib/pangea/toolbar/word_card/word_zoom_widget.dart +++ b/lib/pangea/toolbar/word_card/word_zoom_widget.dart @@ -29,14 +29,12 @@ class WordZoomWidget extends StatelessWidget { final VoidCallback? onDismissNewWordOverlay; final Function(LemmaInfoResponse, String)? onFlagTokenInfo; - final Function(String)? setEmoji; const WordZoomWidget({ super.key, required this.token, required this.construct, required this.langCode, - this.setEmoji, this.onClose, this.wordIsNew = false, this.event, @@ -151,7 +149,6 @@ class WordZoomWidget extends StatelessWidget { LemmaReactionPicker( constructId: construct, langCode: langCode, - onSetEmoji: setEmoji, event: event, ), LemmaMeaningDisplay( diff --git a/lib/pangea/user/user_controller.dart b/lib/pangea/user/user_controller.dart index 59bb13d01..43b9736ea 100644 --- a/lib/pangea/user/user_controller.dart +++ b/lib/pangea/user/user_controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart' as matrix; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; @@ -187,25 +186,11 @@ class UserController { // so it waits for analytics to finish initializing. Analytics waits for user controller to // finish initializing, so this would cause a deadlock. if (analyticsProfile!.isEmpty) { - MatrixState.pangeaController.getAnalytics.initCompleter.future - .timeout(const Duration(seconds: 10)) - .then((_) { - updateAnalyticsProfile( - level: MatrixState - .pangeaController.getAnalytics.constructListModel.level, - ); - }).catchError((e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "publicProfile": analyticsProfile?.toJson(), - "userId": client.userID, - }, - level: - e is TimeoutException ? SentryLevel.warning : SentryLevel.error, - ); - }); + final analyticsService = + MatrixState.pangeaController.matrixState.analyticsDataService; + + final data = await analyticsService.derivedData; + updateAnalyticsProfile(level: data.level); } } diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 24f1eaca6..b64c855e7 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -172,17 +172,6 @@ abstract class ClientManager { onSoftLogout: enableSoftLogout ? (client) => client.refreshAccessToken() : null, // #Pangea - syncFilter: Filter( - room: RoomFilter( - state: StateFilter(lazyLoadMembers: true), - timeline: StateFilter( - notTypes: [ - PangeaEventTypes.construct, - PangeaEventTypes.summaryAnalytics, - ], - ), - ), - ), shouldReplaceRoomLastEvent: (_, event) { return event.content.tryGet(ModelKey.transcription) == null && !event.type.startsWith("p.") && diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index f45fc2b26..603437164 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -20,6 +20,7 @@ import 'package:universal_html/html.dart' as html; import 'package:url_launcher/url_launcher_string.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -74,6 +75,8 @@ class MatrixState extends State with WidgetsBindingObserver { static late PangeaController pangeaController; static PangeaAnyState pAnyState = PangeaAnyState(); late StreamSubscription? _uriListener; + + final Map _analyticsServices = {}; // Pangea# SharedPreferences get store => widget.store; @@ -99,6 +102,18 @@ class MatrixState extends State with WidgetsBindingObserver { return widget.clients[_activeClient]; } + // #Pangea + AnalyticsDataService get analyticsDataService { + if (_analyticsServices[client.clientName] == null) { + Logs().w( + 'Tried to access AnalyticsDataService for client ${client.clientName}, but it does not exist.', + ); + _analyticsServices[client.clientName] = AnalyticsDataService(client); + } + return _analyticsServices[client.clientName]!; + } + // Pangea# + VoipPlugin? voipPlugin; bool get isMultiAccount => widget.clients.length > 1; @@ -287,8 +302,11 @@ class MatrixState extends State with WidgetsBindingObserver { Future _setLanguageListener() async { await pangeaController.userController.initialize(); _languageListener?.cancel(); - _languageListener = pangeaController.userController.languageStream.stream - .listen((_) => _setAppLanguage()); + _languageListener = + pangeaController.userController.languageStream.stream.listen((update) { + _setAppLanguage(); + analyticsDataService.updateService.onUpdateLanguages(update); + }); } void _setAppLanguage() { @@ -413,6 +431,9 @@ class MatrixState extends State with WidgetsBindingObserver { c.onNotification.stream.listen(showLocalNotification); }); } + // #Pangea + _analyticsServices[name] ??= AnalyticsDataService(c); + // Pangea# } void _cancelSubs(String name) { @@ -427,6 +448,8 @@ class MatrixState extends State with WidgetsBindingObserver { // #Pangea onUiaRequest[name]?.cancel(); onUiaRequest.remove(name); + _analyticsServices[name]?.dispose(); + _analyticsServices.remove(name); // Pangea# } diff --git a/pubspec.lock b/pubspec.lock index 96df7a2eb..cc9dce04b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1555,10 +1555,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -2480,26 +2480,26 @@ packages: dependency: transitive description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" timezone: dependency: transitive description: