From af395d0aebd614db6855feb7efabda1497b817c1 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:13:34 -0500 Subject: [PATCH] 4825 vocabulary practice (#4826) * chore: move logic for lastUsedByActivityType into ConstructIdentifier * feat: vocab practice * add vocab activity progress bar * fix: shuffle audio practice choices * update UI of vocab practice Added buttons, increased text size and change position, cards flip over and turn red/green on click and respond to hover input * add xp sparkle, shimmering choice card placeholder * spacing changes fix padding, make choice cards spacing/sizing responsive to screen size, replace shimmer cards with stationary circle indicator * don't include duplicate lemma choices * use constructID and show lemma/emoji on choice cards add method to clear cache in case the results was an error, and add a retry button on error * gain xp immediately and take out continue session also refactor the choice cards to have separate widgets for each type and a parent widget to give each an id for xp sparkle * add practice finished page with analytics * Color tweaks on completed page and time card placeholder * add timer * give XP for bonuses and change timer to use stopwatch * simplify card logic, lock practice when few vocab words * merge analytics changes and fix bugs * reload on language change - derive XP data from new analytics - Don't allow any clicks after correct answer selected * small fixes, added tooltip, added copy to l10 * small tweaks and comments * formatting and import sorting --------- Co-authored-by: avashilling <165050625+avashilling@users.noreply.github.com> --- lib/config/routes.dart | 11 + lib/l10n/intl_en.arb | 17 +- .../analytics_data_service.dart | 10 +- .../analytics_details_popup.dart | 69 +++ .../construct_use_type_enum.dart | 49 +- .../level_up/level_popup_progess_bar.dart | 3 - .../animated_progress_bar.dart | 9 +- lib/pangea/common/utils/async_state.dart | 23 +- .../instructions/instructions_enum.dart | 4 + lib/pangea/lemmas/lemma_info_repo.dart | 10 + .../activity_type_enum.dart | 38 +- .../lemma_activity_generator.dart | 61 ++- .../message_activity_request.dart | 11 +- .../practice_activity_model.dart | 17 +- .../practice_generation_repo.dart | 27 +- .../message_practice/practice_controller.dart | 7 +- .../choice_cards/audio_choice_card.dart | 50 ++ .../choice_cards/game_choice_card.dart | 186 ++++++++ .../choice_cards/meaning_choice_card.dart | 97 ++++ .../completed_activity_session_view.dart | 292 ++++++++++++ .../vocab_practice/percent_marker_bar.dart | 71 +++ lib/pangea/vocab_practice/stat_card.dart | 70 +++ .../vocab_audio_activity_generator.dart | 29 ++ .../vocab_meaning_activity_generator.dart | 32 ++ .../vocab_practice/vocab_practice_page.dart | 430 ++++++++++++++++++ .../vocab_practice_session_model.dart | 253 +++++++++++ .../vocab_practice_session_repo.dart | 102 +++++ .../vocab_practice/vocab_practice_view.dart | 334 ++++++++++++++ .../vocab_practice/vocab_timer_widget.dart | 91 ++++ 29 files changed, 2322 insertions(+), 81 deletions(-) create mode 100644 lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart create mode 100644 lib/pangea/vocab_practice/choice_cards/game_choice_card.dart create mode 100644 lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart create mode 100644 lib/pangea/vocab_practice/completed_activity_session_view.dart create mode 100644 lib/pangea/vocab_practice/percent_marker_bar.dart create mode 100644 lib/pangea/vocab_practice/stat_card.dart create mode 100644 lib/pangea/vocab_practice/vocab_audio_activity_generator.dart create mode 100644 lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart create mode 100644 lib/pangea/vocab_practice/vocab_practice_page.dart create mode 100644 lib/pangea/vocab_practice/vocab_practice_session_model.dart create mode 100644 lib/pangea/vocab_practice/vocab_practice_session_repo.dart create mode 100644 lib/pangea/vocab_practice/vocab_practice_view.dart create mode 100644 lib/pangea/vocab_practice/vocab_timer_widget.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 1ea1fdac7..a2487a95a 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -59,6 +59,7 @@ import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; import 'package:fluffychat/widgets/config_viewer.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; @@ -573,6 +574,16 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'practice', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const VocabPractice(), + ); + }, + ), GoRoute( path: ':construct', pageBuilder: (context, state) { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index d05b4c408..0568a011a 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5008,6 +5008,16 @@ } } }, + "constructUseCorLMDesc": "Correct vocab definition practice", + "constructUseIncLMDesc": "Incorrect vocab definition practice", + "constructUseCorLADesc": "Correct vocab audio practice", + "constructUseIncLADesc": "Incorrect vocab audio practice", + "constructUseBonus": "Bonus during vocab practice", + "practiceVocab": "Practice vocabulary", + "selectMeaning": "Select the meaning", + "selectAudio": "Select the matching audio", + "congratulations": "Congratulations!", + "anotherRound": "Another round", "ssoDialogTitle": "Waiting for sign in to complete", "ssoDialogDesc": "We opened a new tab so you can sign in securely.", "ssoDialogHelpText": "🤔 If you didn't see the new tab, please check your popup blocker.", @@ -5016,6 +5026,9 @@ "recordingPermissionDenied": "Permission denied. Enable recording permissions to record audio messages.", "genericWebRecordingError": "Something went wrong. We recommend using the Chrome browser when recording messages.", "screenSizeWarning": "For the best experience using this application, please expand your screen size.", + "noActivityRequest": "No current activity request.", + "quit": "Quit", + "congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.", "noSavedActivitiesYet": "Activities will appear here once they are completed and saved.", "practiceActivityCompleted": "Practice activity completed", "changeCourse": "Change course", @@ -5026,5 +5039,7 @@ "announcementsChatDesc": "Only space admin can post.", "inOngoingActivity": "You have an ongoing activity!", "activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic", - "activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic" + "activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic", + "mustHave10Words" : "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!" + } diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 91765d2a6..5a0b2ce1e 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -53,7 +53,7 @@ class AnalyticsDataService { AnalyticsSyncController? _syncController; final ConstructMergeTable _mergeTable = ConstructMergeTable(); - Completer _initCompleter = Completer(); + Completer initCompleter = Completer(); AnalyticsDataService(Client client) { updateDispatcher = AnalyticsUpdateDispatcher(this); @@ -77,7 +77,7 @@ class AnalyticsDataService { return _analyticsClient!; } - bool get isInitializing => !_initCompleter.isCompleted; + bool get isInitializing => !initCompleter.isCompleted; Future getAnalyticsRoom(LanguageModel l2) => _analyticsClientGetter.client.getMyAnalyticsRoom(l2); @@ -155,7 +155,7 @@ class AnalyticsDataService { Logs().e("Error initializing analytics: $e, $s"); } finally { Logs().i("Analytics database initialized."); - _initCompleter.complete(); + initCompleter.complete(); updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([])); } } @@ -173,7 +173,7 @@ class AnalyticsDataService { Future reinitialize() async { Logs().i("Reinitializing analytics database."); - _initCompleter = Completer(); + initCompleter = Completer(); await _clearDatabase(); await _initDatabase(_analyticsClientGetter.client); } @@ -192,7 +192,7 @@ class AnalyticsDataService { } Future _ensureInitialized() => - _initCompleter.isCompleted ? Future.value() : _initCompleter.future; + initCompleter.isCompleted ? Future.value() : initCompleter.future; int numConstructs(ConstructTypeEnum type) => _mergeTable.uniqueConstructsByType(type); diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index 16fca4898..e895d830e 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:fluffychat/l10n/l10n.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'; @@ -179,6 +182,72 @@ class ConstructAnalyticsViewState extends State { ), ), ), + floatingActionButton: + widget.view == ConstructTypeEnum.vocab && widget.construct == null + ? _buildVocabPracticeButton(context) + : null, ); } } + +Widget _buildVocabPracticeButton(BuildContext context) { + // Check if analytics is loaded first + if (MatrixState + .pangeaController.matrixState.analyticsDataService.isInitializing) { + return FloatingActionButton.extended( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Loading vocabulary data...', + ), + behavior: SnackBarBehavior.floating, + ), + ); + }, + label: Text(L10n.of(context).practiceVocab), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ); + } + + final vocabCount = MatrixState + .pangeaController.matrixState.analyticsDataService + .numConstructs(ConstructTypeEnum.vocab); + final hasEnoughVocab = vocabCount >= 10; + + return FloatingActionButton.extended( + onPressed: hasEnoughVocab + ? () { + context.go( + "/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice", + ); + } + : () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + L10n.of(context).mustHave10Words, + ), + behavior: SnackBarBehavior.floating, + ), + ); + }, + backgroundColor: + hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer, + foregroundColor: hasEnoughVocab + ? null + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!hasEnoughVocab) ...[ + const Icon(Icons.lock_outline, size: 18), + const SizedBox(width: 4), + ], + Text(L10n.of(context).practiceVocab), + ], + ), + ); +} diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index 82efcd489..54d57ba03 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -69,8 +69,19 @@ enum ConstructUseTypeEnum { /// lemma collected by clicking on it click, + /// Bonus XP, ignored by level analytics page + bonus, + /// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client - nan + nan, + + // vocab lemma definition activity + corLM, + incLM, + + // vocab lemma audio activity + corLA, + incLA, } extension ConstructUseTypeExtension on ConstructUseTypeEnum { @@ -140,8 +151,18 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseIgnMmDesc; case ConstructUseTypeEnum.click: return L10n.of(context).constructUseCollected; + case ConstructUseTypeEnum.bonus: + return L10n.of(context).constructUseBonus; case ConstructUseTypeEnum.nan: return L10n.of(context).constructUseNanDesc; + case ConstructUseTypeEnum.corLM: + return L10n.of(context).constructUseCorLMDesc; + case ConstructUseTypeEnum.incLM: + return L10n.of(context).constructUseIncLMDesc; + case ConstructUseTypeEnum.corLA: + return L10n.of(context).constructUseCorLADesc; + case ConstructUseTypeEnum.incLA: + return L10n.of(context).constructUseIncLADesc; } } @@ -162,10 +183,14 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corPA: case ConstructUseTypeEnum.incPA: case ConstructUseTypeEnum.ignPA: + case ConstructUseTypeEnum.corLM: + case ConstructUseTypeEnum.incLM: return ActivityTypeEnum.wordMeaning.icon; case ConstructUseTypeEnum.ignWL: case ConstructUseTypeEnum.incWL: case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.incLA: return ActivityTypeEnum.wordFocusListening.icon; case ConstructUseTypeEnum.incHWL: case ConstructUseTypeEnum.ignHWL: @@ -192,6 +217,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return Icons.help; case ConstructUseTypeEnum.click: return Icons.format_color_text; + case ConstructUseTypeEnum.bonus: + return Icons.star; } } @@ -206,6 +233,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corPA: case ConstructUseTypeEnum.corWL: case ConstructUseTypeEnum.corM: + case ConstructUseTypeEnum.corLM: + case ConstructUseTypeEnum.corLA: return 5; case ConstructUseTypeEnum.pvm: @@ -223,6 +252,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corIt: case ConstructUseTypeEnum.corMM: + case ConstructUseTypeEnum.bonus: return 1; case ConstructUseTypeEnum.ignIt: @@ -249,6 +279,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incWL: case ConstructUseTypeEnum.incHWL: case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.incLA: return -2; } } @@ -289,6 +321,11 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.em: case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.nan: + case ConstructUseTypeEnum.corLM: + case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.bonus: return false; } } @@ -313,6 +350,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corHWL: case ConstructUseTypeEnum.ignHWL: case ConstructUseTypeEnum.incHWL: + case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.incLA: return LearningSkillsEnum.hearing; case ConstructUseTypeEnum.corPA: case ConstructUseTypeEnum.ignPA: @@ -328,9 +367,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignMM: case ConstructUseTypeEnum.em: case ConstructUseTypeEnum.click: + case ConstructUseTypeEnum.corLM: + case ConstructUseTypeEnum.incLM: return LearningSkillsEnum.reading; case ConstructUseTypeEnum.pvm: return LearningSkillsEnum.speaking; + case ConstructUseTypeEnum.bonus: case ConstructUseTypeEnum.nan: return LearningSkillsEnum.other; } @@ -354,6 +396,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.em: case ConstructUseTypeEnum.corMM: + case ConstructUseTypeEnum.corLM: + case ConstructUseTypeEnum.corLA: return SpaceAnalyticsSummaryEnum.numChoicesCorrect; case ConstructUseTypeEnum.incIt: @@ -364,6 +408,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incL: case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.incMM: + case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.incLA: return SpaceAnalyticsSummaryEnum.numChoicesIncorrect; case ConstructUseTypeEnum.ignIt: @@ -375,6 +421,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignM: case ConstructUseTypeEnum.ignMM: case ConstructUseTypeEnum.click: + case ConstructUseTypeEnum.bonus: case ConstructUseTypeEnum.nan: return null; } 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 25d74c8cf..893d1fcfc 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,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; class LevelPopupProgressBar extends StatefulWidget { @@ -35,8 +34,6 @@ class LevelPopupProgressBarState extends State { return AnimatedProgressBar( height: widget.height, widthPercent: width, - barColor: AppConfig.goldLight, - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, duration: widget.duration, ); } diff --git a/lib/pangea/analytics_summary/animated_progress_bar.dart b/lib/pangea/analytics_summary/animated_progress_bar.dart index d8e04d305..eece5bf6d 100644 --- a/lib/pangea/analytics_summary/animated_progress_bar.dart +++ b/lib/pangea/analytics_summary/animated_progress_bar.dart @@ -10,14 +10,14 @@ class AnimatedProgressBar extends StatelessWidget { final double widthPercent; final Color barColor; - final Color backgroundColor; + final Color? backgroundColor; final Duration? duration; const AnimatedProgressBar({ required this.height, required this.widthPercent, - required this.barColor, - required this.backgroundColor, + this.barColor = AppConfig.goldLight, + this.backgroundColor, this.duration, super.key, }); @@ -38,7 +38,8 @@ class AnimatedProgressBar extends StatelessWidget { borderRadius: const BorderRadius.all( Radius.circular(AppConfig.borderRadius), ), - color: backgroundColor, + color: backgroundColor ?? + Theme.of(context).colorScheme.secondaryContainer, ), ), ), diff --git a/lib/pangea/common/utils/async_state.dart b/lib/pangea/common/utils/async_state.dart index f14b88a61..a2f15adbe 100644 --- a/lib/pangea/common/utils/async_state.dart +++ b/lib/pangea/common/utils/async_state.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -71,6 +72,8 @@ abstract class AsyncLoader { T? get value => isLoaded ? (state.value as AsyncLoaded).value : null; + final Completer completer = Completer(); + void dispose() { _disposed = true; state.dispose(); @@ -79,7 +82,7 @@ abstract class AsyncLoader { Future fetch(); Future load() async { - if (state.value is AsyncLoading || state.value is AsyncLoaded) { + if (state.value is AsyncLoaded) { // If already loading or loaded, do nothing. return; } @@ -90,19 +93,19 @@ abstract class AsyncLoader { final result = await fetch(); if (_disposed) return; state.value = AsyncState.loaded(result); - } on HttpException catch (e) { + completer.complete(result); + } catch (e, s) { + completer.completeError(e); if (!_disposed) { state.value = AsyncState.error(e); } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: {}, - ); - if (!_disposed) { - state.value = AsyncState.error(e); + if (e is! HttpException) { + ErrorHandler.logError( + e: e, + s: s, + data: {}, + ); } } } diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index c23cce1db..7fff582d9 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -31,6 +31,7 @@ enum InstructionsEnum { noSavedActivitiesYet, setLemmaEmoji, disableLanguageTools, + selectMeaning, } extension InstructionsEnumExtension on InstructionsEnum { @@ -41,6 +42,7 @@ extension InstructionsEnumExtension on InstructionsEnum { case InstructionsEnum.ttsDisabled: return l10n.ttsDisbledTitle; case InstructionsEnum.chooseWordAudio: + case InstructionsEnum.selectMeaning: case InstructionsEnum.chooseEmoji: case InstructionsEnum.activityPlannerOverview: case InstructionsEnum.speechToText: @@ -125,6 +127,8 @@ extension InstructionsEnumExtension on InstructionsEnum { return ""; case InstructionsEnum.disableLanguageTools: return l10n.disableLanguageToolsDesc; + case InstructionsEnum.selectMeaning: + return l10n.selectMeaning; } } diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 8dfd5a711..00b95922a 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -83,6 +83,16 @@ class LemmaInfoRepo { } } + ///clear cache of a specific request to retry if failed + static void clearCache(LemmaInfoRequest request) { + final key = request.hashCode.toString(); + _cache.remove(key); + } + + static void clearAllCache() { + _cache.clear(); + } + static Future> _safeFetch( String token, LemmaInfoRequest request, diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index f94a544b4..6a3134f5b 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -11,7 +11,9 @@ enum ActivityTypeEnum { lemmaId, emoji, morphId, - messageMeaning; + messageMeaning, + lemmaMeaning, + lemmaAudio; bool get includeTTSOnClick { switch (this) { @@ -23,11 +25,13 @@ enum ActivityTypeEnum { return false; case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaAudio: + case ActivityTypeEnum.lemmaMeaning: return true; } } - ActivityTypeEnum fromString(String value) { + static ActivityTypeEnum fromString(String value) { final split = value.split('.').last; switch (split) { // used to be called multiple_choice, but we changed it to word_meaning @@ -52,6 +56,12 @@ enum ActivityTypeEnum { return ActivityTypeEnum.morphId; case 'message_meaning': return ActivityTypeEnum.messageMeaning; // TODO: Add to L10n + case 'lemma_meaning': + case 'lemmaMeaning': + return ActivityTypeEnum.lemmaMeaning; + case 'lemma_audio': + case 'lemmaAudio': + return ActivityTypeEnum.lemmaAudio; default: throw Exception('Unknown activity type: $split'); } @@ -96,7 +106,17 @@ enum ActivityTypeEnum { ConstructUseTypeEnum.corMM, ConstructUseTypeEnum.incMM, ConstructUseTypeEnum.ignMM, - ]; // TODO: Add to L10n + ]; + case ActivityTypeEnum.lemmaAudio: + return [ + ConstructUseTypeEnum.corLA, + ConstructUseTypeEnum.incLA, + ]; + case ActivityTypeEnum.lemmaMeaning: + return [ + ConstructUseTypeEnum.corLM, + ConstructUseTypeEnum.incLM, + ]; } } @@ -116,6 +136,10 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.corM; case ActivityTypeEnum.messageMeaning: return ConstructUseTypeEnum.corMM; + case ActivityTypeEnum.lemmaAudio: + return ConstructUseTypeEnum.corLA; + case ActivityTypeEnum.lemmaMeaning: + return ConstructUseTypeEnum.corLM; } } @@ -135,15 +159,21 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.incM; case ActivityTypeEnum.messageMeaning: return ConstructUseTypeEnum.incMM; + case ActivityTypeEnum.lemmaAudio: + return ConstructUseTypeEnum.incLA; + case ActivityTypeEnum.lemmaMeaning: + return ConstructUseTypeEnum.incLM; } } IconData get icon { switch (this) { case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.lemmaMeaning: return Icons.translate; case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaAudio: return Icons.volume_up; case ActivityTypeEnum.lemmaId: return Symbols.dictionary; @@ -168,6 +198,8 @@ enum ActivityTypeEnum { case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.morphId: case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.lemmaAudio: return 1; } } diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index f805260e5..dafef270c 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; @@ -17,7 +18,7 @@ class LemmaActivityGenerator { debugger(when: kDebugMode && req.targetTokens.length != 1); final token = req.targetTokens.first; - final choices = await _lemmaActivityDistractors(token); + final choices = await lemmaActivityDistractors(token); // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( @@ -26,27 +27,25 @@ class LemmaActivityGenerator { targetTokens: [token], langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( - choices: choices, + choices: choices.map((c) => c.lemma).toSet(), answers: {token.lemma.text}, ), ), ); } - static Future> _lemmaActivityDistractors( + static Future> lemmaActivityDistractors( PangeaToken token, ) async { final constructs = await MatrixState .pangeaController.matrixState.analyticsDataService .getAggregatedConstructs(ConstructTypeEnum.vocab); - final List lemmas = - constructs.values.map((c) => c.lemma).toSet().toList(); - + final List constructIds = constructs.keys.toList(); // Offload computation to an isolate - final Map distances = + final Map distances = await compute(_computeDistancesInIsolate, { - 'lemmas': lemmas, + 'lemmas': constructIds, 'target': token.lemma.text, }); @@ -54,29 +53,53 @@ class LemmaActivityGenerator { final sortedLemmas = distances.keys.toList() ..sort((a, b) => distances[a]!.compareTo(distances[b]!)); - // Take the shortest 4 - final choices = sortedLemmas.take(4).toSet(); - if (choices.isEmpty) { - return {token.lemma.text}; + // Skip the first 7 lemmas (to avoid very similar and conjugated forms of verbs) if we have enough lemmas + final int startIndex = sortedLemmas.length > 11 ? 7 : 0; + + // Take up to 4 lemmas ensuring uniqueness by lemma text + final List uniqueByLemma = []; + for (int i = startIndex; i < sortedLemmas.length; i++) { + final cid = sortedLemmas[i]; + if (!uniqueByLemma.any((c) => c.lemma == cid.lemma)) { + uniqueByLemma.add(cid); + if (uniqueByLemma.length == 4) break; + } } - if (!choices.contains(token.lemma.text)) { - choices.add(token.lemma.text); + if (uniqueByLemma.isEmpty) { + return {token.vocabConstructID}; } - return choices; + + // Ensure the target lemma (token.vocabConstructID) is included while keeping unique lemma texts + final int existingIndex = uniqueByLemma + .indexWhere((c) => c.lemma == token.vocabConstructID.lemma); + if (existingIndex >= 0) { + uniqueByLemma[existingIndex] = token.vocabConstructID; + } else { + if (uniqueByLemma.length < 4) { + uniqueByLemma.add(token.vocabConstructID); + } else { + uniqueByLemma[uniqueByLemma.length - 1] = token.vocabConstructID; + } + } + + //shuffle so correct answer isn't always first + uniqueByLemma.shuffle(); + + return uniqueByLemma.toSet(); } // isolate helper function - static Map _computeDistancesInIsolate( + static Map _computeDistancesInIsolate( Map params, ) { - final List lemmas = params['lemmas']; + final List lemmas = params['lemmas']; final String target = params['target']; // Calculate Levenshtein distances - final Map distances = {}; + final Map distances = {}; for (final lemma in lemmas) { - distances[lemma] = _levenshteinDistanceSync(target, lemma); + distances[lemma] = _levenshteinDistanceSync(target, lemma.lemma); } return distances; } diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index f3427514e..1200440b1 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -51,9 +51,6 @@ class MessageActivityRequest { final String userL1; final String userL2; - final String messageText; - final List messageTokens; - final List targetTokens; final ActivityTypeEnum targetType; final MorphFeaturesEnum? targetMorphFeature; @@ -63,8 +60,6 @@ class MessageActivityRequest { MessageActivityRequest({ required this.userL1, required this.userL2, - required this.messageText, - required this.messageTokens, required this.activityQualityFeedback, required this.targetTokens, required this.targetType, @@ -79,8 +74,6 @@ class MessageActivityRequest { return { 'user_l1': userL1, 'user_l2': userL2, - 'message_text': messageText, - 'message_tokens': messageTokens.map((e) => e.toJson()).toList(), 'activity_quality_feedback': activityQualityFeedback?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), 'target_type': targetType.name, @@ -93,7 +86,6 @@ class MessageActivityRequest { if (identical(this, other)) return true; return other is MessageActivityRequest && - other.messageText == messageText && other.targetType == targetType && other.activityQualityFeedback?.feedbackText == activityQualityFeedback?.feedbackText && @@ -103,8 +95,7 @@ class MessageActivityRequest { @override int get hashCode { - return messageText.hashCode ^ - targetType.hashCode ^ + return targetType.hashCode ^ activityQualityFeedback.hashCode ^ targetTokens.hashCode ^ targetMorphFeature.hashCode; diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index c9b047de3..b6016be5f 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -53,8 +53,7 @@ class PracticeActivityModel { ); bool onMultipleChoiceSelect( - PangeaToken token, - PracticeChoice choice, + String choiceContent, ) { if (multipleChoiceContent == null) { debugger(when: kDebugMode); @@ -68,24 +67,23 @@ class PracticeActivityModel { if (practiceTarget.isComplete || practiceTarget.record.alreadyHasMatchResponse( - choice.form.cId, - choice.choiceContent, + targetTokens.first.vocabConstructID, + choiceContent, )) { // the user has already selected this choice // so we don't want to record it again return false; } - final bool isCorrect = - multipleChoiceContent!.isCorrect(choice.choiceContent); + final bool isCorrect = multipleChoiceContent!.isCorrect(choiceContent); // NOTE: the response is associated with the contructId of the choice, not the selected token // example: the user selects the word "cat" to match with the emoji 🐶 // the response is associated with correct word "dog", not the word "cat" practiceTarget.record.addResponse( - cId: choice.form.cId, + cId: targetTokens.first.vocabConstructID, target: practiceTarget, - text: choice.choiceContent, + text: choiceContent, score: isCorrect ? 1 : 0, ); @@ -165,8 +163,7 @@ class PracticeActivityModel { return PracticeActivityModel( langCode: json['lang_code'] as String, - activityType: - ActivityTypeEnum.wordMeaning.fromString(json['activity_type']), + activityType: ActivityTypeEnum.fromString(json['activity_type']), multipleChoiceContent: json['content'] != null ? MultipleChoiceActivity.fromJson(contentMap) : null, diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 07b9b2944..118f01a9e 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -21,6 +21,8 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Represents an item in the completion cache. @@ -70,7 +72,7 @@ class PracticeRepo { messageInfo: messageInfo, ); - _setCached(req, res); + await _setCached(req, res); return Result.value(res.activity); } on HttpException catch (e, s) { return Result.error(e, s); @@ -119,6 +121,10 @@ class PracticeRepo { return EmojiActivityGenerator.get(req, messageInfo: messageInfo); case ActivityTypeEnum.lemmaId: return LemmaActivityGenerator.get(req); + case ActivityTypeEnum.lemmaMeaning: + return VocabMeaningActivityGenerator.get(req); + case ActivityTypeEnum.lemmaAudio: + return VocabAudioActivityGenerator.get(req); case ActivityTypeEnum.morphId: return MorphActivityGenerator.get(req); case ActivityTypeEnum.wordMeaning: @@ -161,16 +167,15 @@ class PracticeRepo { return null; } - static void _setCached( + static Future _setCached( MessageActivityRequest req, MessageActivityResponse res, - ) { - _storage.write( - req.hashCode.toString(), - _RequestCacheItem( - practiceActivity: res.activity, - timestamp: DateTime.now(), - ).toJson(), - ); - } + ) => + _storage.write( + req.hashCode.toString(), + _RequestCacheItem( + practiceActivity: res.activity, + timestamp: DateTime.now(), + ).toJson(), + ); } diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index bd62a0288..58835a72e 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -100,9 +100,6 @@ class PracticeController with ChangeNotifier { final req = MessageActivityRequest( userL1: MatrixState.pangeaController.userController.userL1!.langCode, userL2: MatrixState.pangeaController.userController.userL2!.langCode, - messageText: pangeaMessageEvent.messageDisplayText, - messageTokens: - pangeaMessageEvent.messageDisplayRepresentation?.tokens ?? [], activityQualityFeedback: null, targetTokens: target.tokens, targetType: target.activityType, @@ -156,7 +153,9 @@ class PracticeController with ChangeNotifier { if (_activity == null) return; final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId - ? _activity!.onMultipleChoiceSelect(token, choice) + ? _activity!.onMultipleChoiceSelect( + choice.choiceContent, + ) : _activity!.onMatch(token, choice); final targetId = diff --git a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart new file mode 100644 index 000000000..e80359415 --- /dev/null +++ b/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart'; +import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Displays an audio button with a select label in a row layout +/// TODO: needs a better design and button handling +class AudioChoiceCard extends StatelessWidget { + final String text; + final VoidCallback onPressed; + final bool isCorrect; + final double height; + final bool isEnabled; + + const AudioChoiceCard({ + required this.text, + required this.onPressed, + required this.isCorrect, + this.height = 72.0, + this.isEnabled = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + return GameChoiceCard( + shouldFlip: false, + transformId: text, + onPressed: onPressed, + isCorrect: isCorrect, + height: height, + isEnabled: isEnabled, + child: Row( + children: [ + Expanded( + child: WordAudioButton( + text: text, + uniqueID: "vocab_practice_choice_$text", + langCode: + MatrixState.pangeaController.userController.userL2!.langCode, + ), + ), + Text(L10n.of(context).select), + ], + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart new file mode 100644 index 000000000..5d08a4105 --- /dev/null +++ b/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets +class GameChoiceCard extends StatefulWidget { + final Widget child; + final Widget? altChild; + final VoidCallback onPressed; + final bool isCorrect; + final double height; + final bool shouldFlip; + final String? transformId; + final bool isEnabled; + + const GameChoiceCard({ + required this.child, + this.altChild, + required this.onPressed, + required this.isCorrect, + this.height = 72.0, + this.shouldFlip = false, + this.transformId, + this.isEnabled = true, + super.key, + }); + + @override + State createState() => _GameChoiceCardState(); +} + +class _GameChoiceCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnim; + bool _flipped = false; + bool _isHovered = false; + bool _useAltChild = false; + bool _clicked = false; + + @override + void initState() { + super.initState(); + + if (widget.shouldFlip) { + _controller = AnimationController( + duration: const Duration(milliseconds: 220), + vsync: this, + ); + + _scaleAnim = Tween(begin: 1.0, end: 0.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + + _controller.addListener(_onAnimationUpdate); + } + } + + void _onAnimationUpdate() { + // Swap to altChild when card is almost fully shrunk + if (_controller.value >= 0.95 && !_useAltChild && widget.altChild != null) { + setState(() => _useAltChild = true); + } + + // Mark as flipped when card is fully shrunk + if (_controller.value >= 0.95 && !_flipped) { + setState(() => _flipped = true); + } + } + + @override + void dispose() { + if (widget.shouldFlip) { + _controller.removeListener(_onAnimationUpdate); + _controller.dispose(); + } + super.dispose(); + } + + Future _handleTap() async { + if (!widget.isEnabled) return; + + if (widget.shouldFlip) { + if (_flipped) return; + // Animate forward (shrink), then reverse (expand) + await _controller.forward(); + await _controller.reverse(); + } else { + if (_clicked) return; + setState(() => _clicked = true); + } + + widget.onPressed(); + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + + final Color baseColor = colorScheme.surfaceContainerHighest; + final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08); + final Color tintColor = widget.isCorrect + ? AppConfig.success.withValues(alpha: 0.3) + : AppConfig.error.withValues(alpha: 0.3); + + Widget card = MouseRegion( + onEnter: + widget.isEnabled ? ((_) => setState(() => _isHovered = true)) : null, + onExit: + widget.isEnabled ? ((_) => setState(() => _isHovered = false)) : null, + child: SizedBox( + width: double.infinity, + height: widget.height, + child: GestureDetector( + onTap: _handleTap, + child: widget.shouldFlip + ? AnimatedBuilder( + animation: _scaleAnim, + builder: (context, child) { + final bool showContent = _scaleAnim.value > 0.1; + return Transform.scale( + scaleY: _scaleAnim.value, + child: Container( + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(16), + ), + foregroundDecoration: BoxDecoration( + color: _flipped + ? tintColor + : (_isHovered ? hoverColor : Colors.transparent), + borderRadius: BorderRadius.circular(16), + ), + margin: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 0, + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + height: widget.height, + alignment: Alignment.center, + child: Opacity( + opacity: showContent ? 1.0 : 0.0, + child: _useAltChild && widget.altChild != null + ? widget.altChild! + : widget.child, + ), + ), + ); + }, + ) + : Container( + decoration: BoxDecoration( + color: baseColor, + borderRadius: BorderRadius.circular(16), + ), + foregroundDecoration: BoxDecoration( + color: _clicked + ? tintColor + : (_isHovered ? hoverColor : Colors.transparent), + borderRadius: BorderRadius.circular(16), + ), + margin: + const EdgeInsets.symmetric(vertical: 6, horizontal: 0), + padding: const EdgeInsets.symmetric(horizontal: 16), + height: widget.height, + alignment: Alignment.center, + child: widget.child, + ), + ), + ), + ); + + // Wrap with transform target if transformId is provided + if (widget.transformId != null) { + final transformTargetId = + 'vocab-choice-card-${widget.transformId!.replaceAll(' ', '_')}'; + card = CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey(transformTargetId).link, + child: card, + ); + } + + return card; + } +} diff --git a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart b/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart new file mode 100644 index 000000000..5faeb0398 --- /dev/null +++ b/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; + +/// Choice card for meaning activity with emoji, and alt text on flip +class MeaningChoiceCard extends StatelessWidget { + final String choiceId; + final String displayText; + final String? emoji; + final VoidCallback onPressed; + final bool isCorrect; + final double height; + final bool isEnabled; + + const MeaningChoiceCard({ + required this.choiceId, + required this.displayText, + this.emoji, + required this.onPressed, + required this.isCorrect, + this.height = 72.0, + this.isEnabled = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + final baseTextSize = + (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) * + (height / 72.0).clamp(1.0, 1.4); + final emojiSize = baseTextSize * 1.2; + + return GameChoiceCard( + shouldFlip: true, + transformId: choiceId, + onPressed: onPressed, + isCorrect: isCorrect, + height: height, + isEnabled: isEnabled, + altChild: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (emoji != null && emoji!.isNotEmpty) + SizedBox( + width: height * .7, + height: height, + child: Center( + child: Text( + emoji!, + style: TextStyle(fontSize: emojiSize), + ), + ), + ), + Expanded( + child: Text( + ConstructIdentifier.fromString(choiceId)!.lemma, + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: baseTextSize, + ), + ), + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (emoji != null && emoji!.isNotEmpty) + SizedBox( + width: height * .7, + height: height, + child: Center( + child: Text( + emoji!, + style: TextStyle(fontSize: emojiSize), + ), + ), + ), + Expanded( + child: Text( + displayText, + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: baseTextSize, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/completed_activity_session_view.dart b/lib/pangea/vocab_practice/completed_activity_session_view.dart new file mode 100644 index 000000000..177b6b6eb --- /dev/null +++ b/lib/pangea/vocab_practice/completed_activity_session_view.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; +import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart'; +import 'package:fluffychat/pangea/vocab_practice/stat_card.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; + +class CompletedActivitySessionView extends StatefulWidget { + final VocabPracticeState controller; + const CompletedActivitySessionView(this.controller, {super.key}); + + @override + State createState() => + _CompletedActivitySessionViewState(); +} + +class _CompletedActivitySessionViewState + extends State { + late final Future> progressChangeFuture; + double currentProgress = 0.0; + Uri? avatarUrl; + bool shouldShowRain = false; + + @override + void initState() { + super.initState(); + + // Fetch avatar URL + final client = Matrix.of(context).client; + client.fetchOwnProfile().then((profile) { + if (mounted) { + setState(() => avatarUrl = profile.avatarUrl); + } + }); + + progressChangeFuture = widget.controller.calculateProgressChange( + widget.controller.sessionLoader.value!.totalXpGained, + ); + } + + void _onProgressChangeLoaded(Map progressChange) { + //start with before progress + currentProgress = progressChange['before'] ?? 0.0; + + //switch to after progress after first frame, to activate animation + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + currentProgress = progressChange['after'] ?? 0.0; + // Start the star rain + shouldShowRain = true; + }); + } + }); + } + + String _formatTime(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final username = + Matrix.of(context).client.userID?.split(':').first.substring(1) ?? ''; + final bool accuracyAchievement = + widget.controller.sessionLoader.value!.accuracy == 100; + final bool timeAchievement = + widget.controller.sessionLoader.value!.elapsedSeconds <= 60; + final int numBonusPoints = widget + .controller.sessionLoader.value!.completedUses + .where((use) => use.xp > 0) + .length; + //give double bonus for both, single for one, none for zero + final int bonusXp = (accuracyAchievement && timeAchievement) + ? numBonusPoints * 2 + : (accuracyAchievement || timeAchievement) + ? numBonusPoints + : 0; + + return FutureBuilder>( + future: progressChangeFuture, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const SizedBox.shrink(); + } + + // Initialize progress when data is available + if (currentProgress == 0.0 && !shouldShowRain) { + _onProgressChangeLoaded(snapshot.data!); + } + + return Stack( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16), + child: Column( + children: [ + Text( + L10n.of(context).congratulationsYouveCompletedPractice, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: avatarUrl == null + ? Avatar( + name: username, + showPresence: false, + size: 100, + ) + : ClipOval( + child: MxcImage( + uri: avatarUrl, + width: 100, + height: 100, + ), + ), + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 16.0, + bottom: 16.0, + ), + child: AnimatedProgressBar( + height: 20.0, + widthPercent: currentProgress, + backgroundColor: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + duration: const Duration(milliseconds: 500), + ), + ), + Text( + "+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP", + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + color: AppConfig.goldLight, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + StatCard( + icon: Icons.my_location, + text: + "${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%", + isAchievement: accuracyAchievement, + achievementText: "+ $numBonusPoints XP", + child: PercentMarkerBar( + height: 20.0, + widthPercent: widget + .controller.sessionLoader.value!.accuracy / + 100.0, + markerWidth: 20.0, + markerColor: AppConfig.success, + backgroundColor: !(widget.controller.sessionLoader + .value!.accuracy == + 100) + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + : Color.alphaBlend( + AppConfig.goldLight.withValues(alpha: 0.3), + Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + ), + ), + StatCard( + icon: Icons.alarm, + text: + "${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}", + isAchievement: timeAchievement, + achievementText: "+ $numBonusPoints XP", + child: TimeStarsWidget( + elapsedSeconds: widget + .controller.sessionLoader.value!.elapsedSeconds, + timeForBonus: widget + .controller.sessionLoader.value!.timeForBonus, + ), + ), + Column( + children: [ + //expanded row button + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () => + widget.controller.reloadSession(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).anotherRound, + ), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + ), + onPressed: () => Navigator.of(context).pop(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context).quit, + ), + ], + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + if (shouldShowRain) + const StarRainWidget( + showBlast: true, + rainDuration: Duration(seconds: 5), + ), + ], + ); + }, + ); + } +} + +class TimeStarsWidget extends StatelessWidget { + final int elapsedSeconds; + final int timeForBonus; + + const TimeStarsWidget({ + required this.elapsedSeconds, + required this.timeForBonus, + super.key, + }); + + int get starCount { + if (elapsedSeconds <= timeForBonus) return 5; + if (elapsedSeconds <= timeForBonus * 1.5) return 4; + if (elapsedSeconds <= timeForBonus * 2) return 3; + if (elapsedSeconds <= timeForBonus * 2.5) return 2; + return 1; // anything above 2.5x timeForBonus + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + 5, + (index) => Icon( + index < starCount ? Icons.star : Icons.star_outline, + color: AppConfig.goldLight, + size: 36, + ), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/percent_marker_bar.dart b/lib/pangea/vocab_practice/percent_marker_bar.dart new file mode 100644 index 000000000..2bc8b63a0 --- /dev/null +++ b/lib/pangea/vocab_practice/percent_marker_bar.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; + +// A progress bar with a rounded marker indicating a percentage position + +class PercentMarkerBar extends StatelessWidget { + final double height; + final double widthPercent; + final double markerWidth; + final Color markerColor; + final Color? backgroundColor; + + const PercentMarkerBar({ + required this.height, + required this.widthPercent, + this.markerWidth = 10.0, + this.markerColor = AppConfig.goldLight, + this.backgroundColor, + super.key, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final halfMarker = markerWidth / 2; + + // Calculate the center position of the marker + final targetPosition = totalWidth * widthPercent.clamp(0.0, 1.0); + + // Calculate the start position, clamping to keep marker within bounds + final markerStart = + (targetPosition - halfMarker).clamp(0.0, totalWidth - markerWidth); + + return Stack( + alignment: Alignment.centerLeft, + children: [ + // Background bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Container( + height: height, + width: constraints.maxWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(height / 2), + color: backgroundColor ?? + Theme.of(context).colorScheme.secondaryContainer, + ), + ), + ), + // Marker circle + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Container( + margin: EdgeInsets.only(left: markerStart), + height: height, + width: markerWidth, + decoration: BoxDecoration( + color: markerColor, + borderRadius: BorderRadius.circular(height / 2), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/pangea/vocab_practice/stat_card.dart b/lib/pangea/vocab_practice/stat_card.dart new file mode 100644 index 000000000..604359b7c --- /dev/null +++ b/lib/pangea/vocab_practice/stat_card.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; + +class StatCard extends StatelessWidget { + final IconData icon; + final String text; + final String achievementText; + final Widget child; + final bool isAchievement; + + const StatCard({ + required this.icon, + required this.text, + required this.achievementText, + required this.child, + this.isAchievement = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final backgroundColor = isAchievement + ? Color.alphaBlend( + Theme.of(context).colorScheme.surface.withAlpha(170), + AppConfig.goldLight, + ) + : colorScheme.surfaceContainer; + + return Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + icon, + ), + const SizedBox(width: 8), + Text( + text, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (isAchievement) ...[ + const Spacer(), + Text( + achievementText, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart b/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart new file mode 100644 index 000000000..5ac6eab4f --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart @@ -0,0 +1,29 @@ +import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; + +class VocabAudioActivityGenerator { + static Future get( + MessageActivityRequest req, + ) async { + final token = req.targetTokens.first; + final choices = + await LemmaActivityGenerator.lemmaActivityDistractors(token); + + final choicesList = choices.map((c) => c.lemma).toList(); + choicesList.shuffle(); + + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: req.targetType, + targetTokens: [token], + langCode: req.userL2, + multipleChoiceContent: MultipleChoiceActivity( + choices: choicesList.toSet(), + answers: {token.lemma.text}, + ), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart b/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart new file mode 100644 index 000000000..28ac3a02c --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart @@ -0,0 +1,32 @@ +import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; + +class VocabMeaningActivityGenerator { + static Future get( + MessageActivityRequest req, + ) async { + final token = req.targetTokens.first; + final choices = + await LemmaActivityGenerator.lemmaActivityDistractors(token); + + if (!choices.contains(token.vocabConstructID)) { + choices.add(token.vocabConstructID); + } + + final Set constructIdChoices = choices.map((c) => c.string).toSet(); + + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: req.targetType, + targetTokens: [token], + langCode: req.userL2, + multipleChoiceContent: MultipleChoiceActivity( + choices: constructIdChoices, + answers: {token.vocabConstructID.string}, + ), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/vocab_practice/vocab_practice_page.dart new file mode 100644 index 000000000..0c05d1092 --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_practice_page.dart @@ -0,0 +1,430 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class SessionLoader extends AsyncLoader { + @override + Future fetch() => + VocabPracticeSessionRepo.currentSession; +} + +class VocabPractice extends StatefulWidget { + const VocabPractice({super.key}); + + @override + VocabPracticeState createState() => VocabPracticeState(); +} + +class VocabPracticeState extends State { + SessionLoader sessionLoader = SessionLoader(); + PracticeActivityModel? currentActivity; + bool isLoadingActivity = true; + bool isAwaitingNextActivity = false; + String? activityError; + + bool isLoadingLemmaInfo = false; + final Map _choiceTexts = {}; + final Map _choiceEmojis = {}; + + StreamSubscription? _languageStreamSubscription; + bool _sessionClearedDueToLanguageChange = false; + + @override + void initState() { + super.initState(); + _startSession(); + _listenToLanguageChanges(); + } + + @override + void dispose() { + _languageStreamSubscription?.cancel(); + if (isComplete) { + VocabPracticeSessionRepo.clearSession(); + } else if (!_sessionClearedDueToLanguageChange) { + //don't save if session was cleared due to language change + _saveCurrentTime(); + } + sessionLoader.dispose(); + super.dispose(); + } + + void _saveCurrentTime() { + if (sessionLoader.isLoaded) { + VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + } + } + + /// Resets all session state without disposing the widget + void _resetState() { + currentActivity = null; + isLoadingActivity = true; + isAwaitingNextActivity = false; + activityError = null; + isLoadingLemmaInfo = false; + _choiceTexts.clear(); + _choiceEmojis.clear(); + } + + bool get isComplete => + sessionLoader.isLoaded && sessionLoader.value!.hasCompletedCurrentGroup; + + double get progress => + sessionLoader.isLoaded ? sessionLoader.value!.progress : 0.0; + + int get availableActivities => sessionLoader.isLoaded + ? sessionLoader.value!.currentAvailableActivities + : 0; + + int get completedActivities => + sessionLoader.isLoaded ? sessionLoader.value!.currentIndex : 0; + + int get elapsedSeconds => + sessionLoader.isLoaded ? sessionLoader.value!.elapsedSeconds : 0; + + void updateElapsedTime(int seconds) { + if (sessionLoader.isLoaded) { + sessionLoader.value!.elapsedSeconds = seconds; + } + } + + Future _waitForAnalytics() async { + if (!MatrixState.pangeaController.matrixState.analyticsDataService + .initCompleter.isCompleted) { + MatrixState.pangeaController.initControllers(); + await MatrixState.pangeaController.matrixState.analyticsDataService + .initCompleter.future; + } + } + + void _listenToLanguageChanges() { + _languageStreamSubscription = MatrixState + .pangeaController.userController.languageStream.stream + .listen((_) async { + // If language changed, clear session and back out of vocab practice + if (await _shouldReloadSession()) { + _sessionClearedDueToLanguageChange = true; + await VocabPracticeSessionRepo.clearSession(); + if (mounted) { + Navigator.of(context).pop(); + } + } + }); + } + + Future _startSession() async { + await _waitForAnalytics(); + await sessionLoader.load(); + + // If user languages have changed since last session, clear session + if (await _shouldReloadSession()) { + await VocabPracticeSessionRepo.clearSession(); + sessionLoader.dispose(); + sessionLoader = SessionLoader(); + await sessionLoader.load(); + } + + loadActivity(); + } + + // check if current l1 and l2 have changed from those of the loaded session + Future _shouldReloadSession() async { + if (!sessionLoader.isLoaded) return false; + + final session = sessionLoader.value!; + final currentL1 = + MatrixState.pangeaController.userController.userL1?.langCode; + final currentL2 = + MatrixState.pangeaController.userController.userL2?.langCode; + + if (session.userL1 != currentL1 || session.userL2 != currentL2) { + return true; + } + return false; + } + + Future completeActivitySession() async { + if (!sessionLoader.isLoaded) return; + + _saveCurrentTime(); + sessionLoader.value!.finishSession(); + await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + + setState(() {}); + } + + Future reloadSession() async { + await showFutureLoadingDialog( + context: context, + future: () async { + // Clear current session storage, dispose old session loader, and clear state variables + await VocabPracticeSessionRepo.clearSession(); + sessionLoader.dispose(); + sessionLoader = SessionLoader(); + _resetState(); + await _startSession(); + }, + ); + + if (mounted) { + setState(() {}); + } + } + + Future?> getExampleMessage( + ConstructIdentifier construct, + ) async { + final ConstructUses constructUse = await Matrix.of(context) + .analyticsDataService + .getConstructUse(construct); + for (final use in constructUse.cappedUses) { + if (use.metadata.eventId == null || use.metadata.roomId == null) { + continue; + } + + final room = MatrixState.pangeaController.matrixState.client + .getRoomById(use.metadata.roomId!); + if (room == null) continue; + + final event = await room.getEventById(use.metadata.eventId!); + if (event == null) continue; + + final timeline = await room.getTimeline(); + final pangeaMessageEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == + MatrixState.pangeaController.matrixState.client.userID, + ); + + final tokens = pangeaMessageEvent.messageDisplayRepresentation?.tokens; + if (tokens == null || tokens.isEmpty) continue; + final token = tokens.firstWhereOrNull( + (token) => token.text.content == use.form, + ); + if (token == null) continue; + + final text = pangeaMessageEvent.messageDisplayText; + final tokenText = token.text.content; + int tokenIndex = text.indexOf(tokenText); + if (tokenIndex == -1) continue; + + final beforeSubstring = text.substring(0, tokenIndex); + if (beforeSubstring.length != beforeSubstring.characters.length) { + tokenIndex = beforeSubstring.characters.length; + } + + final int tokenLength = tokenText.characters.length; + final before = text.characters.take(tokenIndex).toString(); + final after = text.characters.skip(tokenIndex + tokenLength).toString(); + return [ + TextSpan(text: before), + TextSpan( + text: tokenText, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: after), + ]; + } + + return null; + } + + Future loadActivity() async { + if (!sessionLoader.isLoaded) { + try { + await sessionLoader.completer.future; + } catch (_) { + return; + } + } + + if (!mounted) return; + + setState(() { + isAwaitingNextActivity = false; + currentActivity = null; + isLoadingActivity = true; + activityError = null; + _choiceTexts.clear(); + _choiceEmojis.clear(); + }); + + final session = sessionLoader.value!; + final activityRequest = session.currentActivityRequest; + if (activityRequest == null) { + setState(() { + activityError = L10n.of(context).noActivityRequest; + isLoadingActivity = false; + }); + return; + } + + final result = await PracticeRepo.getPracticeActivity( + activityRequest, + messageInfo: {}, + ); + if (result.isError) { + activityError = L10n.of(context).oopsSomethingWentWrong; + } else { + currentActivity = result.result!; + } + + // Prefetch lemma info for meaning activities before marking ready + if (currentActivity != null && + currentActivity!.activityType == ActivityTypeEnum.lemmaMeaning) { + final choices = currentActivity!.multipleChoiceContent!.choices.toList(); + await _prefetchLemmaInfo(choices); + } + + if (mounted) { + setState(() => isLoadingActivity = false); + } + } + + Future onSelectChoice(String choice) async { + if (currentActivity == null) return; + final activity = currentActivity!; + + activity.onMultipleChoiceSelect(choice); + final correct = activity.multipleChoiceContent!.isCorrect(choice); + + // Submit answer immediately (records use and gives XP) + sessionLoader.value!.submitAnswer(activity, correct); + await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + + final transformTargetId = + 'vocab-choice-card-${choice.replaceAll(' ', '_')}'; + if (correct) { + OverlayUtil.showPointsGained(transformTargetId, 5, context); + } else { + OverlayUtil.showPointsGained(transformTargetId, -2, context); + } + if (!correct) return; + + // display the fact that the choice was correct before loading the next activity + setState(() => isAwaitingNextActivity = true); + await Future.delayed(const Duration(milliseconds: 1000)); + setState(() => isAwaitingNextActivity = false); + + // Only move to next activity when answer is correct + sessionLoader.value!.completeActivity(activity); + await VocabPracticeSessionRepo.updateSession(sessionLoader.value!); + + if (isComplete) { + await completeActivitySession(); + } + + await loadActivity(); + } + + Future> calculateProgressChange(int xpGained) async { + final derivedData = await MatrixState + .pangeaController.matrixState.analyticsDataService.derivedData; + final currentLevel = derivedData.level; + final currentXP = derivedData.totalXP; + + final minXPForCurrentLevel = + DerivedAnalyticsDataModel.calculateXpWithLevel(currentLevel); + final minXPForNextLevel = derivedData.minXPForNextLevel; + + final xpRange = minXPForNextLevel - minXPForCurrentLevel; + + final progressBefore = + ((currentXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); + + final newTotalXP = currentXP + xpGained; + final progressAfter = + ((newTotalXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0); + + return { + 'before': progressBefore, + 'after': progressAfter, + }; + } + + @override + Widget build(BuildContext context) => VocabPracticeView(this); + + String getChoiceText(String choiceId) { + if (_choiceTexts.containsKey(choiceId)) return _choiceTexts[choiceId]!; + final cId = ConstructIdentifier.fromString(choiceId); + return cId?.lemma ?? choiceId; + } + + String? getChoiceEmoji(String choiceId) => _choiceEmojis[choiceId]; + + //fetches display info for all choices from constructIDs + Future _prefetchLemmaInfo(List choiceIds) async { + if (!mounted) return; + setState(() => isLoadingLemmaInfo = true); + + final results = await Future.wait( + choiceIds.map((id) async { + final cId = ConstructIdentifier.fromString(id); + if (cId == null) { + return null; + } + try { + final result = await cId.getLemmaInfo({}); + return result; + } catch (e) { + return null; + } + }), + ); + + // Check if any result is an error + for (int i = 0; i < results.length; i++) { + final res = results[i]; + if (res != null && res.isError) { + // Clear cache for failed items so retry will fetch fresh + final failedId = choiceIds[i]; + final cId = ConstructIdentifier.fromString(failedId); + if (cId != null) { + LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({})); + } + + if (mounted) { + setState(() { + activityError = L10n.of(context).oopsSomethingWentWrong; + isLoadingLemmaInfo = false; + }); + } + return; + } + // Update choice texts/emojis if successful + if (res != null && !res.isError) { + final id = choiceIds[i]; + final info = res.result!; + _choiceTexts[id] = info.meaning; + _choiceEmojis[id] = _choiceEmojis[id] ?? info.emoji.firstOrNull; + } + } + + if (mounted) { + setState(() => isLoadingLemmaInfo = false); + } + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_session_model.dart b/lib/pangea/vocab_practice/vocab_practice_session_model.dart new file mode 100644 index 000000000..0cbe7f270 --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_practice_session_model.dart @@ -0,0 +1,253 @@ +import 'dart:math'; + +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/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; +import 'package:fluffychat/pangea/lemmas/lemma.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class VocabPracticeSessionModel { + final DateTime startedAt; + final List sortedConstructIds; + final List activityTypes; + final String userL1; + final String userL2; + + int currentIndex; + int currentGroup; + + final List completedUses; + bool finished; + int elapsedSeconds; + + VocabPracticeSessionModel({ + required this.startedAt, + required this.sortedConstructIds, + required this.activityTypes, + required this.userL1, + required this.userL2, + required this.completedUses, + this.currentIndex = 0, + this.currentGroup = 0, + this.finished = false, + this.elapsedSeconds = 0, + }) : assert( + activityTypes.every( + (t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio} + .contains(t), + ), + ), + assert( + activityTypes.length == practiceGroupSize, + ); + + static const int practiceGroupSize = 10; + + int get currentAvailableActivities => min( + ((currentGroup + 1) * practiceGroupSize), + sortedConstructIds.length, + ); + + bool get hasCompletedCurrentGroup => + currentIndex >= currentAvailableActivities; + + int get timeForBonus => 60; + + double get progress => + (currentIndex / currentAvailableActivities).clamp(0.0, 1.0); + + List get currentPracticeGroup => sortedConstructIds + .skip(currentGroup * practiceGroupSize) + .take(practiceGroupSize) + .toList(); + + ConstructIdentifier? get currentConstructId { + if (currentIndex < 0 || hasCompletedCurrentGroup) { + return null; + } + return currentPracticeGroup[currentIndex % practiceGroupSize]; + } + + ActivityTypeEnum? get currentActivityType { + if (currentIndex < 0 || hasCompletedCurrentGroup) { + return null; + } + return activityTypes[currentIndex % practiceGroupSize]; + } + + MessageActivityRequest? get currentActivityRequest { + final constructId = currentConstructId; + if (constructId == null || currentActivityType == null) return null; + + final activityType = currentActivityType; + return MessageActivityRequest( + userL1: userL1, + userL2: userL2, + activityQualityFeedback: null, + targetTokens: [ + PangeaToken( + lemma: Lemma( + text: constructId.lemma, + saveVocab: true, + form: constructId.lemma, + ), + pos: constructId.category, + text: PangeaTokenText.fromString(constructId.lemma), + morph: {}, + ), + ], + targetType: activityType!, + targetMorphFeature: null, + ); + } + + int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp); + + double get accuracy { + if (completedUses.isEmpty) return 0.0; + final correct = completedUses.where((use) => use.xp > 0).length; + final result = correct / completedUses.length; + return (result * 100).truncateToDouble(); + } + + void finishSession() { + finished = true; + + // give bonus XP uses for each construct if earned + if (accuracy >= 100) { + final bonusUses = completedUses + .where((use) => use.xp > 0) + .map( + (use) => OneConstructUse( + useType: ConstructUseTypeEnum.bonus, + constructType: use.constructType, + metadata: ConstructUseMetaData( + roomId: use.metadata.roomId, + timeStamp: DateTime.now(), + ), + category: use.category, + lemma: use.lemma, + form: use.form, + xp: ConstructUseTypeEnum.bonus.pointValue, + ), + ) + .toList(); + + MatrixState + .pangeaController.matrixState.analyticsDataService.updateService + .addAnalytics( + null, + bonusUses, + ); + } + + if (elapsedSeconds <= timeForBonus) { + final bonusUses = completedUses + .where((use) => use.xp > 0) + .map( + (use) => OneConstructUse( + useType: ConstructUseTypeEnum.bonus, + constructType: use.constructType, + metadata: ConstructUseMetaData( + roomId: use.metadata.roomId, + timeStamp: DateTime.now(), + ), + category: use.category, + lemma: use.lemma, + form: use.form, + xp: ConstructUseTypeEnum.bonus.pointValue, + ), + ) + .toList(); + + MatrixState + .pangeaController.matrixState.analyticsDataService.updateService + .addAnalytics( + null, + bonusUses, + ); + } + } + + void submitAnswer(PracticeActivityModel activity, bool isCorrect) { + final useType = isCorrect + ? activity.activityType.correctUse + : activity.activityType.incorrectUse; + + final use = OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: activity.targetTokens.first.pos, + lemma: activity.targetTokens.first.lemma.text, + form: activity.targetTokens.first.lemma.text, + xp: useType.pointValue, + ); + + completedUses.add(use); + + // Give XP immediately + MatrixState.pangeaController.matrixState.analyticsDataService.updateService + .addAnalytics( + null, + [use], + ); + } + + void completeActivity(PracticeActivityModel activity) { + currentIndex += 1; + } + + factory VocabPracticeSessionModel.fromJson(Map json) { + return VocabPracticeSessionModel( + startedAt: DateTime.parse(json['startedAt'] as String), + sortedConstructIds: (json['sortedConstructIds'] as List) + .map((e) => ConstructIdentifier.fromJson(e)) + .whereType() + .toList(), + activityTypes: (json['activityTypes'] as List) + .map( + (e) => ActivityTypeEnum.values.firstWhere( + (at) => at.name == (e as String), + ), + ) + .whereType() + .toList(), + userL1: json['userL1'] as String, + userL2: json['userL2'] as String, + currentIndex: json['currentIndex'] as int, + currentGroup: json['currentGroup'] as int, + completedUses: (json['completedUses'] as List?) + ?.map((e) => OneConstructUse.fromJson(e)) + .whereType() + .toList() ?? + [], + finished: json['finished'] as bool? ?? false, + elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'startedAt': startedAt.toIso8601String(), + 'sortedConstructIds': sortedConstructIds.map((e) => e.toJson()).toList(), + 'activityTypes': activityTypes.map((e) => e.name).toList(), + 'userL1': userL1, + 'userL2': userL2, + 'currentIndex': currentIndex, + 'currentGroup': currentGroup, + 'completedUses': completedUses.map((e) => e.toJson()).toList(), + 'finished': finished, + 'elapsedSeconds': elapsedSeconds, + }; + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart new file mode 100644 index 000000000..955b65d98 --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class VocabPracticeSessionRepo { + static final GetStorage _storage = GetStorage('vocab_practice_session'); + + static Future get currentSession async { + final cached = _getCached(); + if (cached != null) { + return cached; + } + + final r = Random(); + final activityTypes = [ + ActivityTypeEnum.lemmaMeaning, + //ActivityTypeEnum.lemmaAudio, + ]; + + final types = List.generate( + VocabPracticeSessionModel.practiceGroupSize, + (_) => activityTypes[r.nextInt(activityTypes.length)], + ); + + final targets = await _fetch(); + final session = VocabPracticeSessionModel( + userL1: MatrixState.pangeaController.userController.userL1!.langCode, + userL2: MatrixState.pangeaController.userController.userL2!.langCode, + startedAt: DateTime.now(), + sortedConstructIds: targets, + activityTypes: types, + completedUses: [], + ); + await _setCached(session); + return session; + } + + static Future updateSession( + VocabPracticeSessionModel session, + ) => + _setCached(session); + + static Future reloadSession() async { + _storage.erase(); + return currentSession; + } + + static Future clearSession() => _storage.erase(); + + static Future> _fetch() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab) + .then((map) => map.values.toList()); + + // maintain a Map of ConstructIDs to last use dates and a sorted list of ConstructIDs + // based on last use. Update the map / list on practice completion + final Map constructLastUseMap = {}; + final List sortedTargetIds = []; + for (final construct in constructs) { + constructLastUseMap[construct.id] = construct.lastUsed; + sortedTargetIds.add(construct.id); + } + + sortedTargetIds.sort((a, b) { + final dateA = constructLastUseMap[a]; + final dateB = constructLastUseMap[b]; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + return sortedTargetIds; + } + + static VocabPracticeSessionModel? _getCached() { + final keys = List.from(_storage.getKeys()); + if (keys.isEmpty) return null; + try { + final json = _storage.read(keys.first) as Map; + return VocabPracticeSessionModel.fromJson(json); + } catch (e) { + _storage.remove(keys.first); + return null; + } + } + + static Future _setCached(VocabPracticeSessionModel session) async { + await _storage.erase(); + await _storage.write( + session.startedAt.toIso8601String(), + session.toJson(), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/vocab_practice/vocab_practice_view.dart new file mode 100644 index 000000000..12d85e408 --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_practice_view.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/vocab_practice/choice_cards/audio_choice_card.dart'; +import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart'; +import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; +import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class VocabPracticeView extends StatelessWidget { + final VocabPracticeState controller; + + const VocabPracticeView(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + spacing: 8.0, + children: [ + Expanded( + child: AnimatedProgressBar( + height: 20.0, + widthPercent: controller.progress, + barColor: Theme.of(context).colorScheme.primary, + ), + ), + //keep track of state to update timer + ValueListenableBuilder( + valueListenable: controller.sessionLoader.state, + builder: (context, state, __) { + if (state is AsyncLoaded) { + return VocabTimerWidget( + key: ValueKey(state.value.startedAt), + initialSeconds: state.value.elapsedSeconds, + onTimeUpdate: controller.updateElapsedTime, + isRunning: !controller.isComplete, + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + ), + body: MaxWidthBody( + withScrolling: false, + padding: const EdgeInsets.all(0.0), + showBorder: false, + child: Expanded( + child: controller.isComplete + ? CompletedActivitySessionView(controller) + : _OngoingActivitySessionView(controller), + ), + ), + ); + } +} + +class _OngoingActivitySessionView extends StatelessWidget { + final VocabPracticeState controller; + const _OngoingActivitySessionView(this.controller); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.sessionLoader.state, + builder: (context, state, __) { + return switch (state) { + AsyncError(:final error) => + ErrorIndicator(message: error.toString()), + AsyncLoaded(:final value) => + value.currentConstructId != null && + value.currentActivityType != null + ? _VocabActivityView( + value.currentConstructId!, + value.currentActivityType!, + controller, + ) + : const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), + ), + _ => const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator.adaptive(), + ), + ), + }; + }, + ); + } +} + +class _VocabActivityView extends StatelessWidget { + final ConstructIdentifier constructId; + final ActivityTypeEnum activityType; + final VocabPracticeState controller; + + const _VocabActivityView( + this.constructId, + this.activityType, + this.controller, + ); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + //per-activity instructions, add switch statement once there are more types + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.selectMeaning, + padding: EdgeInsets.symmetric(horizontal: 16.0), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + constructId.lemma, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + _ExampleMessageWidget(controller, constructId), + Flexible( + child: _ActivityChoicesWidget( + controller, + activityType, + constructId, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _ExampleMessageWidget extends StatelessWidget { + final VocabPracticeState controller; + final ConstructIdentifier constructId; + + const _ExampleMessageWidget(this.controller, this.constructId); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: controller.getExampleMessage(constructId), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const SizedBox(); + } + + return Padding( + //styling like sent message bubble + padding: const EdgeInsets.all(16.0), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Color.alphaBlend( + Colors.white.withAlpha(180), + ThemeData.dark().colorScheme.primary, + ), + borderRadius: BorderRadius.circular(16), + ), + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: + AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + children: snapshot.data!, + ), + ), + ), + ); + }, + ); + } +} + +class _ActivityChoicesWidget extends StatelessWidget { + final VocabPracticeState controller; + final ActivityTypeEnum activityType; + final ConstructIdentifier constructId; + + const _ActivityChoicesWidget( + this.controller, + this.activityType, + this.constructId, + ); + + @override + Widget build(BuildContext context) { + if (controller.activityError != null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + //allow try to reload activity in case of error + ErrorIndicator(message: controller.activityError!), + const SizedBox(height: 16), + TextButton.icon( + onPressed: controller.loadActivity, + icon: const Icon(Icons.refresh), + label: Text(L10n.of(context).tryAgain), + ), + ], + ); + } + + final activity = controller.currentActivity; + if (controller.isLoadingActivity || + activity == null || + (activity.activityType == ActivityTypeEnum.lemmaMeaning && + controller.isLoadingLemmaInfo)) { + return Container( + constraints: const BoxConstraints(maxHeight: 400.0), + child: const Center( + child: CircularProgressIndicator.adaptive(), + ), + ); + } + + final choices = activity.multipleChoiceContent!.choices.toList(); + return LayoutBuilder( + builder: (context, constraints) { + //Constrain max height to keep choices together on large screens, and allow shrinking to fit on smaller screens + final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0); + final cardHeight = + (constrainedHeight / (choices.length + 1)).clamp(50.0, 80.0); + + return Container( + constraints: const BoxConstraints(maxHeight: 400.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: choices.map((choiceId) { + final bool isEnabled = !controller.isAwaitingNextActivity; + return _buildChoiceCard( + activity: activity, + choiceId: choiceId, + cardHeight: cardHeight, + isEnabled: isEnabled, + onPressed: () => controller.onSelectChoice(choiceId), + ); + }).toList(), + ), + ), + ); + }, + ); + } + + Widget _buildChoiceCard({ + required activity, + required String choiceId, + required double cardHeight, + required bool isEnabled, + required VoidCallback onPressed, + }) { + final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId); + + switch (activity.activityType) { + case ActivityTypeEnum.lemmaMeaning: + return MeaningChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_meaning_$choiceId', + ), + choiceId: choiceId, + displayText: controller.getChoiceText(choiceId), + emoji: controller.getChoiceEmoji(choiceId), + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: isEnabled, + ); + + case ActivityTypeEnum.lemmaAudio: + return AudioChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_audio_$choiceId', + ), + text: choiceId, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: isEnabled, + ); + + default: + return GameChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_basic_$choiceId', + ), + shouldFlip: false, + transformId: choiceId, + onPressed: onPressed, + isCorrect: isCorrect, + height: cardHeight, + isEnabled: isEnabled, + child: Text(controller.getChoiceText(choiceId)), + ); + } + } +} diff --git a/lib/pangea/vocab_practice/vocab_timer_widget.dart b/lib/pangea/vocab_practice/vocab_timer_widget.dart new file mode 100644 index 000000000..9f949bd5c --- /dev/null +++ b/lib/pangea/vocab_practice/vocab_timer_widget.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class VocabTimerWidget extends StatefulWidget { + final int initialSeconds; + final ValueChanged onTimeUpdate; + final bool isRunning; + + const VocabTimerWidget({ + required this.initialSeconds, + required this.onTimeUpdate, + this.isRunning = true, + super.key, + }); + + @override + VocabTimerWidgetState createState() => VocabTimerWidgetState(); +} + +class VocabTimerWidgetState extends State { + final Stopwatch _stopwatch = Stopwatch(); + late int _initialSeconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _initialSeconds = widget.initialSeconds; + if (widget.isRunning) { + _startTimer(); + } + } + + @override + void didUpdateWidget(VocabTimerWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isRunning && !widget.isRunning) { + _stopTimer(); + } else if (!oldWidget.isRunning && widget.isRunning) { + _startTimer(); + } + } + + @override + void dispose() { + _stopTimer(); + super.dispose(); + } + + void _startTimer() { + _stopwatch.start(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + final currentSeconds = _getCurrentSeconds(); + setState(() {}); + widget.onTimeUpdate(currentSeconds); + }); + } + + void _stopTimer() { + _stopwatch.stop(); + _timer?.cancel(); + _timer = null; + } + + int _getCurrentSeconds() { + return _initialSeconds + (_stopwatch.elapsedMilliseconds / 1000).round(); + } + + String _formatTime(int seconds) { + final minutes = seconds ~/ 60; + final remainingSeconds = seconds % 60; + return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.alarm, size: 20), + const SizedBox(width: 4.0), + Text( + _formatTime(_getCurrentSeconds()), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +}