From b698e2e84feb5a3a097957bcc8fcd74dbee6d3ea Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 14 Jan 2026 16:06:22 -0500 Subject: [PATCH 1/7] feat: grammar practice --- lib/config/routes.dart | 18 +- lib/l10n/intl_en.arb | 6 +- .../analytics_details_popup.dart | 116 ++++++----- .../morph_analytics_list_view.dart | 1 + .../analytics_misc/construct_type_enum.dart | 38 ++-- .../construct_use_type_enum.dart | 18 ++ .../analytics_misc/constructs_model.dart | 2 +- .../lemma_emoji_setter_mixin.dart | 1 - .../analytics_practice_constants.dart} | 2 +- .../analytics_practice_page.dart} | 88 +++++---- .../analytics_practice_session_model.dart} | 25 +-- .../analytics_practice_session_repo.dart | 182 ++++++++++++++++++ .../analytics_practice_view.dart} | 70 +++---- .../choice_cards/audio_choice_card.dart | 2 +- .../choice_cards/game_choice_card.dart | 0 .../choice_cards/meaning_choice_card.dart | 2 +- .../completed_activity_session_view.dart | 16 +- .../morph_category_activity_generator.dart | 65 +++++++ .../percent_marker_bar.dart | 0 .../practice_timer_widget.dart} | 10 +- .../stat_card.dart | 0 .../vocab_audio_activity_generator.dart | 0 .../vocab_meaning_activity_generator.dart | 0 .../activity_type_enum.dart | 56 +++++- .../message_activity_request.dart | 9 + .../practice_activity_model.dart | 40 ++++ .../practice_generation_repo.dart | 7 +- .../vocab_practice_session_repo.dart | 102 ---------- 28 files changed, 573 insertions(+), 303 deletions(-) rename lib/pangea/{vocab_practice/vocab_practice_constants.dart => analytics_practice/analytics_practice_constants.dart} (70%) rename lib/pangea/{vocab_practice/vocab_practice_page.dart => analytics_practice/analytics_practice_page.dart} (84%) rename lib/pangea/{vocab_practice/vocab_practice_session_model.dart => analytics_practice/analytics_practice_session_model.dart} (87%) create mode 100644 lib/pangea/analytics_practice/analytics_practice_session_repo.dart rename lib/pangea/{vocab_practice/vocab_practice_view.dart => analytics_practice/analytics_practice_view.dart} (83%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/audio_choice_card.dart (93%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/game_choice_card.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/meaning_choice_card.dart (96%) rename lib/pangea/{vocab_practice => analytics_practice}/completed_activity_session_view.dart (93%) create mode 100644 lib/pangea/analytics_practice/morph_category_activity_generator.dart rename lib/pangea/{vocab_practice => analytics_practice}/percent_marker_bar.dart (100%) rename lib/pangea/{vocab_practice/vocab_timer_widget.dart => analytics_practice/practice_timer_widget.dart} (87%) rename lib/pangea/{vocab_practice => analytics_practice}/stat_card.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_audio_activity_generator.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_meaning_activity_generator.dart (100%) delete mode 100644 lib/pangea/vocab_practice/vocab_practice_session_repo.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index a2487a95a..2530b02c3 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -38,6 +38,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.dart' 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/empty_analytics_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.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/chat_settings/pages/edit_course.dart'; @@ -59,7 +60,6 @@ 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'; @@ -542,6 +542,18 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'practice', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const AnalyticsPractice( + type: ConstructTypeEnum.morph, + ), + ); + }, + ), GoRoute( path: ':construct', pageBuilder: (context, state) { @@ -580,7 +592,9 @@ abstract class AppRoutes { return defaultPageBuilder( context, state, - const VocabPractice(), + const AnalyticsPractice( + type: ConstructTypeEnum.vocab, + ), ); }, ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 487a6df31..cd54d8644 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5046,5 +5046,9 @@ "voice": "Voice", "youLeftTheChat": "🚪 You left the chat", "downloadInitiated": "Download initiated", - "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site." + "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site.", + "practiceGrammar": "Practice grammar", + "notEnoughToPractice": "Send more messages to unlock practice", + "constructUseCorGCDesc": "Correct grammar category practice", + "constructUseIncGCDesc": "Incorrect grammar category practice" } diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index c816dfb80..4a9f32681 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -183,72 +183,66 @@ class ConstructAnalyticsViewState extends State { ), ), floatingActionButton: - widget.view == ConstructTypeEnum.vocab && widget.construct == null - ? _buildVocabPracticeButton(context) - : null, + widget.construct == null ? _PracticeButton(view: widget.view) : null, ); } } -Widget _buildVocabPracticeButton(BuildContext context) { - // Check if analytics is loaded first - if (MatrixState - .pangeaController.matrixState.analyticsDataService.isInitializing) { +class _PracticeButton extends StatelessWidget { + final ConstructTypeEnum view; + const _PracticeButton({required this.view}); + + void _showSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final analyticsService = Matrix.of(context).analyticsDataService; + if (analyticsService.isInitializing) { + return FloatingActionButton.extended( + onPressed: () => _showSnackbar( + context, + L10n.of(context).loadingPleaseWait, + ), + label: Text(view.practiceButtonText(context)), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ); + } + + final count = analyticsService.numConstructs(view); + final enabled = count >= 10; + 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), + onPressed: enabled + ? () => context.go("/rooms/analytics/${view.name}/practice") + : () => _showSnackbar( + context, + L10n.of(context).notEnoughToPractice, + ), + backgroundColor: + enabled ? null : Theme.of(context).colorScheme.surfaceContainer, + foregroundColor: enabled + ? null + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!enabled) ...[ + const Icon(Icons.lock_outline, size: 18), + const SizedBox(width: 4), + ], + Text(view.practiceButtonText(context)), + ], + ), ); } - - 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).hideCurrentSnackBar(); - 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_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index f923391e0..9be8139e0 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -75,6 +75,7 @@ class MorphAnalyticsListView extends StatelessWidget { childCount: controller.features.length, ), ), + const SliverToBoxAdapter(child: SizedBox(height: 75.0)), ], ), ), diff --git a/lib/pangea/analytics_misc/construct_type_enum.dart b/lib/pangea/analytics_misc/construct_type_enum.dart index 910d3cfbe..ad7e3f0a2 100644 --- a/lib/pangea/analytics_misc/construct_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_type_enum.dart @@ -4,20 +4,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; enum ConstructTypeEnum { /// for vocabulary words vocab, /// for morphs, actually called "Grammar" in the UI... :P - morph, -} + morph; -extension ConstructExtension on ConstructTypeEnum { String get string { switch (this) { case ConstructTypeEnum.vocab: @@ -37,25 +32,6 @@ extension ConstructExtension on ConstructTypeEnum { } } - int get maxXPPerLemma { - switch (this) { - case ConstructTypeEnum.vocab: - return AnalyticsConstants.vocabUseMaxXP; - case ConstructTypeEnum.morph: - return AnalyticsConstants.morphUseMaxXP; - } - } - - String? getDisplayCopy(String category, BuildContext context) { - switch (this) { - case ConstructTypeEnum.morph: - return MorphFeaturesEnumExtension.fromString(category) - .getDisplayCopy(context); - case ConstructTypeEnum.vocab: - return getVocabCategoryName(category, context); - } - } - ProgressIndicatorEnum get indicator { switch (this) { case ConstructTypeEnum.morph: @@ -64,9 +40,17 @@ extension ConstructExtension on ConstructTypeEnum { return ProgressIndicatorEnum.wordsUsed; } } -} -class ConstructTypeUtil { + String practiceButtonText(BuildContext context) { + final l10n = L10n.of(context); + switch (this) { + case ConstructTypeEnum.vocab: + return l10n.practiceVocab; + case ConstructTypeEnum.morph: + return l10n.practiceGrammar; + } + } + static ConstructTypeEnum fromString(String? string) { switch (string) { case 'v': diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index 9dd3ba4c0..028759fe4 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -82,6 +82,10 @@ enum ConstructUseTypeEnum { // vocab lemma audio activity corLA, incLA, + + // grammar category activity + corGC, + incGC, } extension ConstructUseTypeExtension on ConstructUseTypeEnum { @@ -163,6 +167,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseCorLADesc; case ConstructUseTypeEnum.incLA: return L10n.of(context).constructUseIncLADesc; + case ConstructUseTypeEnum.corGC: + return L10n.of(context).constructUseCorGCDesc; + case ConstructUseTypeEnum.incGC: + return L10n.of(context).constructUseIncGCDesc; } } @@ -203,6 +211,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return ActivityTypeEnum.morphId.icon; case ConstructUseTypeEnum.em: return ActivityTypeEnum.emoji.icon; @@ -235,6 +245,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: return 5; case ConstructUseTypeEnum.pvm: @@ -275,6 +286,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: return -1; case ConstructUseTypeEnum.incPA: @@ -326,6 +338,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corLA: case ConstructUseTypeEnum.incLA: case ConstructUseTypeEnum.bonus: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return false; } } @@ -369,6 +383,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return LearningSkillsEnum.reading; case ConstructUseTypeEnum.pvm: return LearningSkillsEnum.speaking; @@ -398,6 +414,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corMM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: return SpaceAnalyticsSummaryEnum.numChoicesCorrect; case ConstructUseTypeEnum.incIt: @@ -410,6 +427,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incMM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: return SpaceAnalyticsSummaryEnum.numChoicesIncorrect; case ConstructUseTypeEnum.ignIt: diff --git a/lib/pangea/analytics_misc/constructs_model.dart b/lib/pangea/analytics_misc/constructs_model.dart index 5f0ee420c..cb053585b 100644 --- a/lib/pangea/analytics_misc/constructs_model.dart +++ b/lib/pangea/analytics_misc/constructs_model.dart @@ -117,7 +117,7 @@ class OneConstructUse { debugger(when: kDebugMode && json['constructType'] == null); final ConstructTypeEnum constructType = json['constructType'] != null - ? ConstructTypeUtil.fromString(json['constructType']) + ? ConstructTypeEnum.fromString(json['constructType']) : ConstructTypeEnum.vocab; final useType = ConstructUseTypeUtil.fromString(json['useType']); diff --git a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart index 65f4a7a09..0719943e3 100644 --- a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart +++ b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_tile.dart'; import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.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'; diff --git a/lib/pangea/vocab_practice/vocab_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart similarity index 70% rename from lib/pangea/vocab_practice/vocab_practice_constants.dart rename to lib/pangea/analytics_practice/analytics_practice_constants.dart index 70bcaf6bc..3d049b597 100644 --- a/lib/pangea/vocab_practice/vocab_practice_constants.dart +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -1,4 +1,4 @@ -class VocabPracticeConstants { +class AnalyticsPracticeConstants { static const int timeForBonus = 60; static const int practiceGroupSize = 10; } diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart similarity index 84% rename from lib/pangea/vocab_practice/vocab_practice_page.dart rename to lib/pangea/analytics_practice/analytics_practice_page.dart index fe379e494..709682e6a 100644 --- a/lib/pangea/vocab_practice/vocab_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -13,6 +13,9 @@ 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/example_message_util.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; @@ -22,47 +25,52 @@ import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_m import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.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 VocabPracticeChoice { +class PracticeChoice { final String choiceId; final String choiceText; final String? choiceEmoji; - const VocabPracticeChoice({ + const PracticeChoice({ required this.choiceId, required this.choiceText, this.choiceEmoji, }); } -class SessionLoader extends AsyncLoader { - @override - Future fetch() => VocabPracticeSessionRepo.get(); -} - -class VocabPractice extends StatefulWidget { - const VocabPractice({super.key}); +class SessionLoader extends AsyncLoader { + final ConstructTypeEnum type; + SessionLoader({required this.type}); @override - VocabPracticeState createState() => VocabPracticeState(); + Future fetch() => + AnalyticsPracticeSessionRepo.get(type); } -class VocabPracticeState extends State with AnalyticsUpdater { - final SessionLoader _sessionLoader = SessionLoader(); +class AnalyticsPractice extends StatefulWidget { + final ConstructTypeEnum type; + const AnalyticsPractice({ + super.key, + required this.type, + }); + + @override + AnalyticsPracticeState createState() => AnalyticsPracticeState(); +} + +class AnalyticsPracticeState extends State + with AnalyticsUpdater { + late final SessionLoader _sessionLoader; final ValueNotifier> activityState = ValueNotifier(const AsyncState.idle()); - final Queue>> - _queue = Queue(); + final Queue>> _queue = + Queue(); - final ValueNotifier activityConstructId = - ValueNotifier(null); + final ValueNotifier activityText = ValueNotifier(null); final ValueNotifier progressNotifier = ValueNotifier(0.0); @@ -74,6 +82,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { @override void initState() { super.initState(); + _sessionLoader = SessionLoader(type: widget.type); _startSession(); _languageStreamSubscription = MatrixState .pangeaController.userController.languageStream.stream @@ -84,13 +93,13 @@ class VocabPracticeState extends State with AnalyticsUpdater { void dispose() { _languageStreamSubscription?.cancel(); if (_isComplete) { - VocabPracticeSessionRepo.clear(); + AnalyticsPracticeSessionRepo.clear(); } else { _saveSession(); } _sessionLoader.dispose(); activityState.dispose(); - activityConstructId.dispose(); + activityText.dispose(); progressNotifier.dispose(); super.dispose(); } @@ -102,19 +111,19 @@ class VocabPracticeState extends State with AnalyticsUpdater { bool get _isComplete => _sessionLoader.value?.isComplete ?? false; - ValueNotifier> get sessionState => + ValueNotifier> get sessionState => _sessionLoader.state; AnalyticsDataService get _analyticsService => Matrix.of(context).analyticsDataService; - List filteredChoices( + List filteredChoices( PracticeTarget target, MultipleChoiceActivity activity, ) { final choices = activity.choices.toList(); final answer = activity.answers.first; - final filtered = []; + final filtered = []; final seenTexts = {}; for (final id in choices) { @@ -129,7 +138,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { (choice) => choice.choiceText == text, ); if (index != -1) { - filtered[index] = VocabPracticeChoice( + filtered[index] = PracticeChoice( choiceId: id, choiceText: text, choiceEmoji: getChoiceEmoji(target, id), @@ -140,7 +149,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { seenTexts.add(text); filtered.add( - VocabPracticeChoice( + PracticeChoice( choiceId: id, choiceText: text, choiceEmoji: getChoiceEmoji(target, id), @@ -164,11 +173,11 @@ class VocabPracticeState extends State with AnalyticsUpdater { _choiceEmojis[target]?[choiceId]; String choiceTargetId(String choiceId) => - 'vocab-choice-card-${choiceId.replaceAll(' ', '_')}'; + '${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}'; void _resetActivityState() { activityState.value = const AsyncState.loading(); - activityConstructId.value = null; + activityText.value = null; } void _resetSessionState() { @@ -187,7 +196,10 @@ class VocabPracticeState extends State with AnalyticsUpdater { Future _saveSession() async { if (_sessionLoader.isLoaded) { - await VocabPracticeSessionRepo.update(_sessionLoader.value!); + await AnalyticsPracticeSessionRepo.update( + widget.type, + _sessionLoader.value!, + ); } } @@ -225,7 +237,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { Future reloadSession() async { _resetActivityState(); _resetSessionState(); - await VocabPracticeSessionRepo.clear(); + await AnalyticsPracticeSessionRepo.clear(); _sessionLoader.reset(); await _startSession(); } @@ -253,7 +265,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { } else { activityState.value = const AsyncState.loading(); final nextActivityCompleter = _queue.removeFirst(); - activityConstructId.value = nextActivityCompleter.key; + activityText.value = nextActivityCompleter.key; final activity = await nextActivityCompleter.value.future; activityState.value = AsyncState.loaded(activity); } @@ -277,7 +289,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { final res = await _fetchActivity(req); if (!mounted) return; - activityConstructId.value = req.targetTokens.first.vocabConstructID; + activityText.value = req.activityText; activityState.value = AsyncState.loaded(res); } catch (e) { if (!mounted) return; @@ -293,7 +305,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { final completer = Completer(); _queue.add( MapEntry( - request.targetTokens.first.vocabConstructID, + request.activityText, completer, ), ); @@ -375,14 +387,14 @@ class VocabPracticeState extends State with AnalyticsUpdater { final use = OneConstructUse( useType: useType, - constructType: ConstructTypeEnum.vocab, + constructType: widget.type, metadata: ConstructUseMetaData( roomId: null, timeStamp: DateTime.now(), ), - category: activity.targetTokens.first.pos, - lemma: activity.targetTokens.first.lemma.text, - form: activity.targetTokens.first.lemma.text, + category: activity.useCategory, + lemma: activity.useLemma, + form: activity.useForm, xp: useType.pointValue, ); @@ -417,5 +429,5 @@ class VocabPracticeState extends State with AnalyticsUpdater { _analyticsService.derivedData; @override - Widget build(BuildContext context) => VocabPracticeView(this); + Widget build(BuildContext context) => AnalyticsPracticeView(this); } diff --git a/lib/pangea/vocab_practice/vocab_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart similarity index 87% rename from lib/pangea/vocab_practice/vocab_practice_session_model.dart rename to lib/pangea/analytics_practice/analytics_practice_session_model.dart index d0437ebe2..9c27769e9 100644 --- a/lib/pangea/vocab_practice/vocab_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -2,12 +2,11 @@ import 'dart:math'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; -class VocabPracticeSessionModel { +class AnalyticsPracticeSessionModel { final DateTime startedAt; final List practiceTargets; final String userL1; @@ -15,22 +14,16 @@ class VocabPracticeSessionModel { VocabPracticeSessionState state; - VocabPracticeSessionModel({ + AnalyticsPracticeSessionModel({ required this.startedAt, required this.practiceTargets, required this.userL1, required this.userL2, VocabPracticeSessionState? state, - }) : assert( - practiceTargets.every( - (t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio} - .contains(t.activityType), - ), - ), - state = state ?? const VocabPracticeSessionState(); + }) : state = state ?? const VocabPracticeSessionState(); int get _availableActivities => min( - VocabPracticeConstants.practiceGroupSize, + AnalyticsPracticeConstants.practiceGroupSize, practiceTargets.length, ); @@ -47,7 +40,7 @@ class VocabPracticeSessionModel { activityQualityFeedback: null, targetTokens: target.tokens, targetType: target.activityType, - targetMorphFeature: null, + targetMorphFeature: target.morphFeature, ); }).toList(); } @@ -64,8 +57,8 @@ class VocabPracticeSessionModel { completedUses: [...state.completedUses, use], ); - factory VocabPracticeSessionModel.fromJson(Map json) { - return VocabPracticeSessionModel( + factory AnalyticsPracticeSessionModel.fromJson(Map json) { + return AnalyticsPracticeSessionModel( startedAt: DateTime.parse(json['startedAt'] as String), practiceTargets: (json['practiceTargets'] as List) .map((e) => PracticeTarget.fromJson(e)) @@ -115,7 +108,7 @@ class VocabPracticeSessionState { bool get _giveAccuracyBonus => accuracy >= 100.0; bool get _giveTimeBonus => - elapsedSeconds <= VocabPracticeConstants.timeForBonus; + elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus; int get bonusXP => accuracyBonusXP + timeBonusXP; diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart new file mode 100644 index 000000000..ecc97ef2a --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -0,0 +1,182 @@ +import 'dart:math'; + +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_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/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnalyticsPracticeSessionRepo { + static final GetStorage _storage = GetStorage('practice_session'); + + static Future get( + ConstructTypeEnum type, + ) async { + final cached = _getCached(type); + if (cached != null) { + return cached; + } + + final r = Random(); + final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type); + + final types = List.generate( + AnalyticsPracticeConstants.practiceGroupSize, + (_) => activityTypes[r.nextInt(activityTypes.length)], + ); + + final List targets = []; + + if (type == ConstructTypeEnum.vocab) { + final constructs = await _fetchVocab(); + final targetCount = min(constructs.length, types.length); + targets.addAll([ + for (var i = 0; i < targetCount; i++) + PracticeTarget( + tokens: [constructs[i].asToken], + activityType: types[i], + ), + ]); + } else { + final morphs = await _fetchMorphs(); + targets.addAll([ + for (final entry in morphs.entries) + PracticeTarget( + tokens: [entry.key], + activityType: types[targets.length], + morphFeature: entry.value, + ), + ]); + } + + final session = AnalyticsPracticeSessionModel( + userL1: MatrixState.pangeaController.userController.userL1!.langCode, + userL2: MatrixState.pangeaController.userController.userL2!.langCode, + startedAt: DateTime.now(), + practiceTargets: targets, + ); + await _setCached(type, session); + return session; + } + + static Future update( + ConstructTypeEnum type, + AnalyticsPracticeSessionModel session, + ) => + _setCached(type, session); + + static Future clear() => _storage.erase(); + + static Future> _fetchVocab() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + return constructs + .where((construct) => construct.lemma.isNotEmpty) + .take(AnalyticsPracticeConstants.practiceGroupSize) + .map((construct) => construct.id) + .toList(); + } + + static Future> _fetchMorphs() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.morph) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + final targets = {}; + final Set seenForms = {}; + + for (final entry in constructs) { + if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + break; + } + + final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); + if (feature == MorphFeaturesEnum.Unknown) { + continue; + } + + for (final use in entry.cappedUses) { + if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + break; + } + + final form = use.form; + if (seenForms.contains(form) || form == null) { + continue; + } + + seenForms.add(form); + final token = PangeaToken( + lemma: Lemma( + text: form, + saveVocab: true, + form: form, + ), + text: PangeaTokenText.fromString(form), + pos: 'other', + morph: {feature: use.lemma}, + ); + targets[token] = feature; + break; + } + } + + return targets; + } + + static AnalyticsPracticeSessionModel? _getCached( + ConstructTypeEnum type, + ) { + try { + final entry = _storage.read(type.name); + if (entry == null) return null; + final json = entry as Map; + return AnalyticsPracticeSessionModel.fromJson(json); + } catch (e) { + _storage.remove(type.name); + return null; + } + } + + static Future _setCached( + ConstructTypeEnum type, + AnalyticsPracticeSessionModel session, + ) async { + await _storage.write( + type.name, + session.toJson(), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart similarity index 83% rename from lib/pangea/vocab_practice/vocab_practice_view.dart rename to lib/pangea/analytics_practice/analytics_practice_view.dart index a196e4186..1e6139c06 100644 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -2,6 +2,13 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; +import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.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'; @@ -9,19 +16,12 @@ 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/practice_activities/practice_activity_model.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; +class AnalyticsPracticeView extends StatelessWidget { + final AnalyticsPracticeState controller; - const VocabPracticeView(this.controller, {super.key}); + const AnalyticsPracticeView(this.controller, {super.key}); @override Widget build(BuildContext context) { @@ -53,8 +53,8 @@ class VocabPracticeView extends StatelessWidget { ValueListenableBuilder( valueListenable: controller.sessionState, builder: (context, state, __) { - if (state is AsyncLoaded) { - return VocabTimerWidget( + if (state is AsyncLoaded) { + return PracticeTimerWidget( key: ValueKey(state.value.startedAt), initialSeconds: state.value.state.elapsedSeconds, onTimeUpdate: controller.updateElapsedTime, @@ -78,12 +78,12 @@ class VocabPracticeView extends StatelessWidget { valueListenable: controller.sessionState, builder: (context, state, __) { return switch (state) { - AsyncError(:final error) => + AsyncError(:final error) => ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => + AsyncLoaded(:final value) => value.isComplete ? CompletedActivitySessionView(state.value, controller) - : _VocabActivityView(controller), + : _AnalyticsActivityView(controller), _ => loading, }; }, @@ -93,10 +93,10 @@ class VocabPracticeView extends StatelessWidget { } } -class _VocabActivityView extends StatelessWidget { - final VocabPracticeState controller; +class _AnalyticsActivityView extends StatelessWidget { + final AnalyticsPracticeState controller; - const _VocabActivityView( + const _AnalyticsActivityView( this.controller, ); @@ -119,10 +119,10 @@ class _VocabActivityView extends StatelessWidget { Expanded( flex: 1, child: ValueListenableBuilder( - valueListenable: controller.activityConstructId, - builder: (context, constructId, __) => constructId != null + valueListenable: controller.activityText, + builder: (context, text, __) => text != null ? Text( - constructId.lemma, + text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -132,19 +132,19 @@ class _VocabActivityView extends StatelessWidget { : const SizedBox(), ), ), - Expanded( - flex: 2, - child: Center( - child: ValueListenableBuilder( - valueListenable: controller.activityConstructId, - builder: (context, constructId, __) => constructId != null - ? _ExampleMessageWidget( - controller.getExampleMessage(constructId), - ) - : const SizedBox(), - ), - ), - ), + // Expanded( + // flex: 2, + // child: Center( + // child: ValueListenableBuilder( + // valueListenable: controller.activityConstructId, + // builder: (context, constructId, __) => constructId != null + // ? _ExampleMessageWidget( + // controller.getExampleMessage(constructId), + // ) + // : const SizedBox(), + // ), + // ), + // ), Expanded( flex: 6, child: _ActivityChoicesWidget(controller), @@ -199,7 +199,7 @@ class _ExampleMessageWidget extends StatelessWidget { } class _ActivityChoicesWidget extends StatelessWidget { - final VocabPracticeState controller; + final AnalyticsPracticeState controller; const _ActivityChoicesWidget( this.controller, diff --git a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart similarity index 93% rename from lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart index dce47c6ee..a7a26864e 100644 --- a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.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 diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart similarity index 100% rename from lib/pangea/vocab_practice/choice_cards/game_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/game_choice_card.dart diff --git a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart similarity index 96% rename from lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart index d4d004074..f51394054 100644 --- a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.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 { diff --git a/lib/pangea/vocab_practice/completed_activity_session_view.dart b/lib/pangea/analytics_practice/completed_activity_session_view.dart similarity index 93% rename from lib/pangea/vocab_practice/completed_activity_session_view.dart rename to lib/pangea/analytics_practice/completed_activity_session_view.dart index 5b9761f1f..28c0cebec 100644 --- a/lib/pangea/vocab_practice/completed_activity_session_view.dart +++ b/lib/pangea/analytics_practice/completed_activity_session_view.dart @@ -3,18 +3,18 @@ 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_practice/analytics_practice_constants.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/percent_marker_bar.dart'; +import 'package:fluffychat/pangea/analytics_practice/stat_card.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_constants.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/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; class CompletedActivitySessionView extends StatelessWidget { - final VocabPracticeSessionModel session; - final VocabPracticeState controller; + final AnalyticsPracticeSessionModel session; + final AnalyticsPracticeState controller; const CompletedActivitySessionView( this.session, this.controller, { @@ -198,7 +198,7 @@ class TimeStarsWidget extends StatelessWidget { }); int get starCount { - const timeForBonus = VocabPracticeConstants.timeForBonus; + const timeForBonus = AnalyticsPracticeConstants.timeForBonus; if (elapsedSeconds <= timeForBonus) return 5; if (elapsedSeconds <= timeForBonus * 1.5) return 4; if (elapsedSeconds <= timeForBonus * 2) return 3; diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart new file mode 100644 index 000000000..66db19137 --- /dev/null +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -0,0 +1,65 @@ +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; +import 'package:fluffychat/pangea/morphs/morph_models.dart'; +import 'package:fluffychat/pangea/morphs/morph_repo.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'; +import 'package:fluffychat/widgets/matrix.dart'; + +class MorphCategoryActivityGenerator { + static Future get( + MessageActivityRequest req, + ) async { + if (req.targetMorphFeature == null) { + throw ArgumentError( + "MorphCategoryActivityGenerator requires a targetMorphFeature", + ); + } + + final feature = req.targetMorphFeature!; + final morphTag = req.targetTokens.first.getMorphTag(feature); + if (morphTag == null) { + throw ArgumentError( + "Token does not have the specified morph feature", + ); + } + + MorphFeaturesAndTags morphs = defaultMorphMapping; + + try { + final resp = await MorphsRepo.get(); + morphs = resp; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {"l2": MatrixState.pangeaController.userController.userL2}, + ); + } + + final List allTags = morphs.getDisplayTags(feature.name); + final List possibleDistractors = allTags + .where( + (tag) => tag.toLowerCase() != morphTag.toLowerCase() && tag != "X", + ) + .toList(); + + possibleDistractors.shuffle(); + final choices = possibleDistractors.take(3).toSet(); + choices.add(morphTag); + + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: req.targetType, + targetTokens: [req.targetTokens.first], + langCode: req.userL2, + morphFeature: feature, + multipleChoiceContent: MultipleChoiceActivity( + choices: choices, + answers: {morphTag}, + ), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/percent_marker_bar.dart b/lib/pangea/analytics_practice/percent_marker_bar.dart similarity index 100% rename from lib/pangea/vocab_practice/percent_marker_bar.dart rename to lib/pangea/analytics_practice/percent_marker_bar.dart diff --git a/lib/pangea/vocab_practice/vocab_timer_widget.dart b/lib/pangea/analytics_practice/practice_timer_widget.dart similarity index 87% rename from lib/pangea/vocab_practice/vocab_timer_widget.dart rename to lib/pangea/analytics_practice/practice_timer_widget.dart index 2ef4d06af..005efe0cc 100644 --- a/lib/pangea/vocab_practice/vocab_timer_widget.dart +++ b/lib/pangea/analytics_practice/practice_timer_widget.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -class VocabTimerWidget extends StatefulWidget { +class PracticeTimerWidget extends StatefulWidget { final int initialSeconds; final ValueChanged onTimeUpdate; final bool isRunning; - const VocabTimerWidget({ + const PracticeTimerWidget({ required this.initialSeconds, required this.onTimeUpdate, this.isRunning = true, @@ -15,10 +15,10 @@ class VocabTimerWidget extends StatefulWidget { }); @override - VocabTimerWidgetState createState() => VocabTimerWidgetState(); + PracticeTimerWidgetState createState() => PracticeTimerWidgetState(); } -class VocabTimerWidgetState extends State { +class PracticeTimerWidgetState extends State { final Stopwatch _stopwatch = Stopwatch(); late int _initialSeconds; Timer? _timer; @@ -33,7 +33,7 @@ class VocabTimerWidgetState extends State { } @override - void didUpdateWidget(VocabTimerWidget oldWidget) { + void didUpdateWidget(PracticeTimerWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isRunning && !widget.isRunning) { _stopTimer(); diff --git a/lib/pangea/vocab_practice/stat_card.dart b/lib/pangea/analytics_practice/stat_card.dart similarity index 100% rename from lib/pangea/vocab_practice/stat_card.dart rename to lib/pangea/analytics_practice/stat_card.dart diff --git a/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart similarity index 100% rename from lib/pangea/vocab_practice/vocab_audio_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_audio_activity_generator.dart diff --git a/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart similarity index 100% rename from lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 6a3134f5b..42696c226 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; enum ActivityTypeEnum { @@ -13,7 +14,8 @@ enum ActivityTypeEnum { morphId, messageMeaning, lemmaMeaning, - lemmaAudio; + lemmaAudio, + grammarCategory; bool get includeTTSOnClick { switch (this) { @@ -27,6 +29,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.lemmaAudio: case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.grammarCategory: return true; } } @@ -62,6 +65,9 @@ enum ActivityTypeEnum { case 'lemma_audio': case 'lemmaAudio': return ActivityTypeEnum.lemmaAudio; + case 'grammar_category': + case 'grammarCategory': + return ActivityTypeEnum.grammarCategory; default: throw Exception('Unknown activity type: $split'); } @@ -117,6 +123,11 @@ enum ActivityTypeEnum { ConstructUseTypeEnum.corLM, ConstructUseTypeEnum.incLM, ]; + case ActivityTypeEnum.grammarCategory: + return [ + ConstructUseTypeEnum.corGC, + ConstructUseTypeEnum.incGC, + ]; } } @@ -140,6 +151,8 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.corLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.corLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.corGC; } } @@ -163,6 +176,8 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.incLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.incLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.incGC; } } @@ -182,6 +197,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.morphId: return Icons.format_shapes; case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.grammarCategory: return Icons.star; // TODO: Add to L10n } } @@ -200,6 +216,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.messageMeaning: case ActivityTypeEnum.lemmaMeaning: case ActivityTypeEnum.lemmaAudio: + case ActivityTypeEnum.grammarCategory: return 1; } } @@ -210,4 +227,41 @@ enum ActivityTypeEnum { ActivityTypeEnum.wordFocusListening, ActivityTypeEnum.morphId, ]; + + static List get vocabPracticeTypes => [ + ActivityTypeEnum.lemmaMeaning, + // ActivityTypeEnum.lemmaAudio, + ]; + + static List get grammarPracticeTypes => [ + ActivityTypeEnum.grammarCategory, + ]; + + static List analyticsPracticeTypes( + ConstructTypeEnum constructType, + ) { + switch (constructType) { + case ConstructTypeEnum.vocab: + return vocabPracticeTypes; + case ConstructTypeEnum.morph: + return grammarPracticeTypes; + } + } + + ConstructTypeEnum get constructType { + switch (this) { + case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.lemmaAudio: + return ConstructTypeEnum.vocab; + case ActivityTypeEnum.morphId: + case ActivityTypeEnum.grammarCategory: + return ConstructTypeEnum.morph; + } + } } diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 1200440b1..50542f32f 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -70,6 +70,15 @@ class MessageActivityRequest { } } + String get activityText { + switch (targetType) { + case ActivityTypeEnum.grammarCategory: + return "${targetTokens.first.vocabConstructID.lemma}: ${targetMorphFeature!.name}"; + default: + return targetTokens.first.vocabConstructID.lemma; + } + } + Map toJson() { return { 'user_l1': userL1, diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index d884e1315..b97955a40 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -47,6 +48,45 @@ class PracticeActivityModel { } } + String get useCategory { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + assert( + morphFeature != null, + "morphFeature is null in PracticeActivityModel.useCategory", + ); + return morphFeature!.name; + case ConstructTypeEnum.vocab: + return targetTokens.first.pos; + } + } + + String get useLemma { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + assert( + morphFeature != null, + "morphFeature is null in PracticeActivityModel.useCategory", + ); + final tag = targetTokens.first.getMorphTag(morphFeature!); + if (tag == null) { + throw ("tag is null in PracticeActivityModel.useLemma"); + } + return tag; + case ConstructTypeEnum.vocab: + return targetTokens.first.lemma.text; + } + } + + String get useForm { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + return targetTokens.first.lemma.form; + case ConstructTypeEnum.vocab: + return targetTokens.first.lemma.text; + } + } + PracticeTarget get practiceTarget => PracticeTarget( tokens: targetTokens, activityType: activityType, diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 118f01a9e..84883cd50 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -9,6 +9,9 @@ import 'package:async/async.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; +import 'package:fluffychat/pangea/analytics_practice/morph_category_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_audio_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; @@ -21,8 +24,6 @@ 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. @@ -125,6 +126,8 @@ class PracticeRepo { return VocabMeaningActivityGenerator.get(req); case ActivityTypeEnum.lemmaAudio: return VocabAudioActivityGenerator.get(req); + case ActivityTypeEnum.grammarCategory: + return MorphCategoryActivityGenerator.get(req); case ActivityTypeEnum.morphId: return MorphActivityGenerator.get(req); case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart deleted file mode 100644 index 2ce376e37..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart +++ /dev/null @@ -1,102 +0,0 @@ -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/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.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() async { - final cached = _getCached(); - if (cached != null) { - return cached; - } - - final r = Random(); - final activityTypes = [ - ActivityTypeEnum.lemmaMeaning, - //ActivityTypeEnum.lemmaAudio, - ]; - - final types = List.generate( - VocabPracticeConstants.practiceGroupSize, - (_) => activityTypes[r.nextInt(activityTypes.length)], - ); - - final constructs = await _fetch(); - final targetCount = min(constructs.length, types.length); - final targets = [ - for (var i = 0; i < targetCount; i++) - PracticeTarget( - tokens: [constructs[i].asToken], - activityType: types[i], - ), - ]; - - final session = VocabPracticeSessionModel( - userL1: MatrixState.pangeaController.userController.userL1!.langCode, - userL2: MatrixState.pangeaController.userController.userL2!.langCode, - startedAt: DateTime.now(), - practiceTargets: targets, - ); - await _setCached(session); - return session; - } - - static Future update( - VocabPracticeSessionModel session, - ) => - _setCached(session); - - static Future clear() => _storage.erase(); - - static Future> _fetch() async { - final constructs = await MatrixState - .pangeaController.matrixState.analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.vocab) - .then((map) => map.values.toList()); - - // sort by last used descending, nulls first - constructs.sort((a, b) { - final dateA = a.lastUsed; - final dateB = b.lastUsed; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); - }); - - return constructs - .where((construct) => construct.lemma.isNotEmpty) - .take(VocabPracticeConstants.practiceGroupSize) - .map((construct) => construct.id) - .toList(); - } - - 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(), - ); - } -} From 326e5c32412774ce8c88954e430dd7ef6aa6705d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 09:59:28 -0500 Subject: [PATCH 2/7] fix example messages for grammar activities, make practice activity model a sealed class --- .../analytics_misc/example_message_util.dart | 7 +- .../analytics_practice_page.dart | 93 +-- .../analytics_practice_view.dart | 46 +- .../morph_category_activity_generator.dart | 3 +- .../vocab_audio_activity_generator.dart | 3 +- .../vocab_meaning_activity_generator.dart | 3 +- .../emoji_activity_generator.dart | 4 +- .../lemma_activity_generator.dart | 4 +- .../lemma_meaning_activity_generator.dart | 4 +- .../message_activity_request.dart | 16 +- .../morph_activity_generator.dart | 6 +- .../practice_activity_model.dart | 558 +++++++++++------- .../practice_activities/practice_target.dart | 9 + .../word_focus_listening_generator.dart | 4 +- .../message_morph_choice.dart | 8 +- .../practice_activity_card.dart | 15 +- .../message_practice/practice_controller.dart | 25 +- .../message_practice/practice_match_card.dart | 6 +- 18 files changed, 486 insertions(+), 328 deletions(-) diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart index 8fec79f8a..43a3e659c 100644 --- a/lib/pangea/analytics_misc/example_message_util.dart +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -10,9 +10,12 @@ import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dar class ExampleMessageUtil { static Future?> getExampleMessage( ConstructUses construct, - Client client, - ) async { + Client client, { + String? form, + }) async { for (final use in construct.cappedUses) { + if (form != null && use.form != form) continue; + final event = await client.getEventByConstructUse(use); if (event == null) continue; diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 709682e6a..250a267d9 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -10,8 +10,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.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_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart'; @@ -64,13 +62,15 @@ class AnalyticsPracticeState extends State with AnalyticsUpdater { late final SessionLoader _sessionLoader; - final ValueNotifier> activityState = - ValueNotifier(const AsyncState.idle()); + final ValueNotifier> + activityState = ValueNotifier(const AsyncState.idle()); - final Queue>> _queue = - Queue(); + final Queue< + MapEntry>> _queue = Queue(); - final ValueNotifier activityText = ValueNotifier(null); + final ValueNotifier activityTarget = + ValueNotifier(null); final ValueNotifier progressNotifier = ValueNotifier(0.0); @@ -99,14 +99,16 @@ class AnalyticsPracticeState extends State } _sessionLoader.dispose(); activityState.dispose(); - activityText.dispose(); + activityTarget.dispose(); progressNotifier.dispose(); super.dispose(); } - PracticeActivityModel? get _currentActivity => - activityState.value is AsyncLoaded - ? (activityState.value as AsyncLoaded).value + MultipleChoicePracticeActivityModel? get _currentActivity => + activityState.value is AsyncLoaded + ? (activityState.value + as AsyncLoaded) + .value : null; bool get _isComplete => _sessionLoader.value?.isComplete ?? false; @@ -177,7 +179,7 @@ class AnalyticsPracticeState extends State void _resetActivityState() { activityState.value = const AsyncState.loading(); - activityText.value = null; + activityTarget.value = null; } void _resetSessionState() { @@ -258,14 +260,15 @@ class AnalyticsPracticeState extends State _continuing = true; try { - if (activityState.value is AsyncIdle) { + if (activityState.value + is AsyncIdle) { await _initActivityData(); } else if (_queue.isEmpty) { await _completeSession(); } else { activityState.value = const AsyncState.loading(); final nextActivityCompleter = _queue.removeFirst(); - activityText.value = nextActivityCompleter.key; + activityTarget.value = nextActivityCompleter.key; final activity = await nextActivityCompleter.value.future; activityState.value = AsyncState.loaded(activity); } @@ -289,7 +292,7 @@ class AnalyticsPracticeState extends State final res = await _fetchActivity(req); if (!mounted) return; - activityText.value = req.activityText; + activityTarget.value = req.practiceTarget; activityState.value = AsyncState.loaded(res); } catch (e) { if (!mounted) return; @@ -302,13 +305,14 @@ class AnalyticsPracticeState extends State Future _fillActivityQueue(List requests) async { for (final request in requests) { - final completer = Completer(); + final completer = Completer(); _queue.add( MapEntry( - request.activityText, + request.practiceTarget, completer, ), ); + try { final res = await _fetchActivity(request); if (!mounted) return; @@ -321,24 +325,28 @@ class AnalyticsPracticeState extends State } } - Future _fetchActivity( + Future _fetchActivity( MessageActivityRequest req, ) async { final result = await PracticeRepo.getPracticeActivity( req, messageInfo: {}, ); - if (result.isError) { + + if (result.isError || + result.result is! MultipleChoicePracticeActivityModel) { throw L10n.of(context).oopsSomethingWentWrong; } + final activityModel = result.result as MultipleChoicePracticeActivityModel; + // Prefetch lemma info for meaning activities before marking ready - if (result.result!.activityType == ActivityTypeEnum.lemmaMeaning) { - final choices = result.result!.multipleChoiceContent!.choices.toList(); - await _fetchLemmaInfo(result.result!.practiceTarget, choices); + if (activityModel.activityType == ActivityTypeEnum.lemmaMeaning) { + final choices = activityModel.multipleChoiceContent.choices.toList(); + await _fetchLemmaInfo(activityModel.practiceTarget, choices); } - return result.result!; + return activityModel; } Future _fetchLemmaInfo( @@ -378,32 +386,14 @@ class AnalyticsPracticeState extends State // Update activity record activity.onMultipleChoiceSelect(choiceConstruct, choiceContent); - final correct = activity.multipleChoiceContent!.isCorrect(choiceContent); - - // Update session model and analytics - final useType = correct - ? activity.activityType.correctUse - : activity.activityType.incorrectUse; - - final use = OneConstructUse( - useType: useType, - constructType: widget.type, - metadata: ConstructUseMetaData( - roomId: null, - timeStamp: DateTime.now(), - ), - category: activity.useCategory, - lemma: activity.useLemma, - form: activity.useForm, - xp: useType.pointValue, - ); + final use = activity.constructUse(choiceContent); _sessionLoader.value!.submitAnswer(use); await _analyticsService.updateService .addAnalytics(choiceTargetId(choiceContent), [use]); await _saveSession(); - if (!correct) return; + if (!activity.multipleChoiceContent.isCorrect(choiceContent)) return; // Display the fact that the choice was correct before loading the next activity await Future.delayed(const Duration(milliseconds: 1000)); @@ -417,11 +407,26 @@ class AnalyticsPracticeState extends State } Future?> getExampleMessage( - ConstructIdentifier construct, + PracticeTarget target, ) async { + final token = target.tokens.first; + final construct = switch (widget.type) { + ConstructTypeEnum.vocab => token.vocabConstructID, + ConstructTypeEnum.morph => token.morphIdByFeature(target.morphFeature!), + }; + + if (construct == null) return null; + + String? form; + if (widget.type == ConstructTypeEnum.morph) { + if (target.morphFeature == null) return null; + form = token.lemma.form; + } + return ExampleMessageUtil.getExampleMessage( await _analyticsService.getConstructUse(construct), Matrix.of(context).client, + form: form, ); } diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 1e6139c06..bb6cf5716 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -119,10 +119,10 @@ class _AnalyticsActivityView extends StatelessWidget { Expanded( flex: 1, child: ValueListenableBuilder( - valueListenable: controller.activityText, - builder: (context, text, __) => text != null + valueListenable: controller.activityTarget, + builder: (context, target, __) => target != null ? Text( - text, + target.promptText(), textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -132,19 +132,19 @@ class _AnalyticsActivityView extends StatelessWidget { : const SizedBox(), ), ), - // Expanded( - // flex: 2, - // child: Center( - // child: ValueListenableBuilder( - // valueListenable: controller.activityConstructId, - // builder: (context, constructId, __) => constructId != null - // ? _ExampleMessageWidget( - // controller.getExampleMessage(constructId), - // ) - // : const SizedBox(), - // ), - // ), - // ), + Expanded( + flex: 2, + child: Center( + child: ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) => target != null + ? _ExampleMessageWidget( + controller.getExampleMessage(target), + ) + : const SizedBox(), + ), + ), + ), Expanded( flex: 6, child: _ActivityChoicesWidget(controller), @@ -211,14 +211,15 @@ class _ActivityChoicesWidget extends StatelessWidget { valueListenable: controller.activityState, builder: (context, state, __) { return switch (state) { - AsyncLoading() => const Center( + AsyncLoading() => const Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator.adaptive(), ), ), - AsyncError(:final error) => Column( + AsyncError(:final error) => + Column( mainAxisAlignment: MainAxisAlignment.center, children: [ //allow try to reload activity in case of error @@ -231,11 +232,12 @@ class _ActivityChoicesWidget extends StatelessWidget { ), ], ), - AsyncLoaded(:final value) => LayoutBuilder( + AsyncLoaded(:final value) => + LayoutBuilder( builder: (context, constraints) { final choices = controller.filteredChoices( value.practiceTarget, - value.multipleChoiceContent!, + value.multipleChoiceContent, ); final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0); @@ -281,7 +283,7 @@ class _ActivityChoicesWidget extends StatelessWidget { } class _ChoiceCard extends StatelessWidget { - final PracticeActivityModel activity; + final MultipleChoicePracticeActivityModel activity; final String choiceId; final String targetId; final VoidCallback onPressed; @@ -302,7 +304,7 @@ class _ChoiceCard extends StatelessWidget { @override Widget build(BuildContext context) { - final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId); + final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId); final activityType = activity.activityType; final constructId = activity.targetTokens.first.vocabConstructID; diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart index 66db19137..d9020b7b5 100644 --- a/lib/pangea/analytics_practice/morph_category_activity_generator.dart +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -50,8 +50,7 @@ class MorphCategoryActivityGenerator { choices.add(morphTag); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: req.targetType, + activity: MorphCategoryPracticeActivityModel( targetTokens: [req.targetTokens.first], langCode: req.userL2, morphFeature: feature, diff --git a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index 5ac6eab4f..bcbbbb721 100644 --- a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -15,8 +15,7 @@ class VocabAudioActivityGenerator { choicesList.shuffle(); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: req.targetType, + activity: VocabAudioPracticeActivityModel( targetTokens: [token], langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( diff --git a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart index 28ac3a02c..cc258a76b 100644 --- a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart @@ -18,8 +18,7 @@ class VocabMeaningActivityGenerator { final Set constructIdChoices = choices.map((c) => c.string).toSet(); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: req.targetType, + activity: VocabMeaningPracticeActivityModel( targetTokens: [token], langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 2bb790379..817162a8b 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -3,7 +3,6 @@ import 'package:async/async.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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/pangea/practice_activities/practice_match.dart'; @@ -65,8 +64,7 @@ class EmojiActivityGenerator { } return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.emoji, + activity: EmojiPracticeActivityModel( targetTokens: req.targetTokens, langCode: req.userL2, matchContent: PracticeMatchActivity( diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index dafef270c..198c3161a 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -5,7 +5,6 @@ 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'; import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; @@ -22,8 +21,7 @@ class LemmaActivityGenerator { // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.lemmaId, + activity: LemmaPracticeActivityModel( targetTokens: [token], langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( diff --git a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index 274334fb3..981668c20 100644 --- a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -4,7 +4,6 @@ import 'package:async/async.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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/pangea/practice_activities/practice_match.dart'; @@ -33,8 +32,7 @@ class LemmaMeaningActivityGenerator { ); return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.wordMeaning, + activity: LemmaMeaningPracticeActivityModel( targetTokens: req.targetTokens, langCode: req.userL2, matchContent: PracticeMatchActivity( diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 50542f32f..824cad45a 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.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_target.dart'; // includes feedback text and the bad activity model class ActivityQualityFeedback { @@ -16,15 +17,6 @@ class ActivityQualityFeedback { required this.badActivity, }); - factory ActivityQualityFeedback.fromJson(Map json) { - return ActivityQualityFeedback( - feedbackText: json['feedback_text'] as String, - badActivity: PracticeActivityModel.fromJson( - json['bad_activity'] as Map, - ), - ); - } - Map toJson() { return { 'feedback_text': feedbackText, @@ -90,6 +82,12 @@ class MessageActivityRequest { }; } + PracticeTarget get practiceTarget => PracticeTarget( + activityType: targetType, + tokens: targetTokens, + morphFeature: targetMorphFeature, + ); + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/lib/pangea/practice_activities/morph_activity_generator.dart b/lib/pangea/practice_activities/morph_activity_generator.dart index aff0eec88..434929490 100644 --- a/lib/pangea/practice_activities/morph_activity_generator.dart +++ b/lib/pangea/practice_activities/morph_activity_generator.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.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/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; @@ -38,11 +37,10 @@ class MorphActivityGenerator { debugger(when: kDebugMode && distractors.length < 3); return MessageActivityResponse( - activity: PracticeActivityModel( + activity: MorphMatchPracticeActivityModel( targetTokens: req.targetTokens, langCode: req.userL2, - activityType: ActivityTypeEnum.morphId, - morphFeature: req.targetMorphFeature, + morphFeature: morphFeature, multipleChoiceContent: MultipleChoiceActivity( choices: distractors, answers: {morphTag}, diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index b97955a40..eac79e60a 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -1,12 +1,8 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - -import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.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/morphs/morph_features_enum.dart'; @@ -16,177 +12,23 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -class PracticeActivityModel { +sealed class PracticeActivityModel { final List targetTokens; final ActivityTypeEnum activityType; - final MorphFeaturesEnum? morphFeature; - final String langCode; - final MultipleChoiceActivity? multipleChoiceContent; - final PracticeMatchActivity? matchContent; - - PracticeActivityModel({ + const PracticeActivityModel({ required this.targetTokens, required this.langCode, required this.activityType, - this.morphFeature, - this.multipleChoiceContent, - this.matchContent, - }) { - if (matchContent == null && multipleChoiceContent == null) { - debugger(when: kDebugMode); - throw ("both matchContent and multipleChoiceContent are null in PracticeActivityModel"); - } - if (matchContent != null && multipleChoiceContent != null) { - debugger(when: kDebugMode); - throw ("both matchContent and multipleChoiceContent are not null in PracticeActivityModel"); - } - if (activityType == ActivityTypeEnum.morphId && morphFeature == null) { - debugger(when: kDebugMode); - throw ("morphFeature is null in PracticeActivityModel"); - } - } - - String get useCategory { - switch (activityType.constructType) { - case ConstructTypeEnum.morph: - assert( - morphFeature != null, - "morphFeature is null in PracticeActivityModel.useCategory", - ); - return morphFeature!.name; - case ConstructTypeEnum.vocab: - return targetTokens.first.pos; - } - } - - String get useLemma { - switch (activityType.constructType) { - case ConstructTypeEnum.morph: - assert( - morphFeature != null, - "morphFeature is null in PracticeActivityModel.useCategory", - ); - final tag = targetTokens.first.getMorphTag(morphFeature!); - if (tag == null) { - throw ("tag is null in PracticeActivityModel.useLemma"); - } - return tag; - case ConstructTypeEnum.vocab: - return targetTokens.first.lemma.text; - } - } - - String get useForm { - switch (activityType.constructType) { - case ConstructTypeEnum.morph: - return targetTokens.first.lemma.form; - case ConstructTypeEnum.vocab: - return targetTokens.first.lemma.text; - } - } + }); PracticeTarget get practiceTarget => PracticeTarget( tokens: targetTokens, activityType: activityType, - morphFeature: morphFeature, ); - bool onMultipleChoiceSelect( - ConstructIdentifier choiceConstruct, - String choice, - ) { - if (multipleChoiceContent == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "in onMultipleChoiceSelect with null multipleChoiceContent", - s: StackTrace.current, - data: toJson(), - ); - return false; - } - - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - choiceConstruct, - choice, - )) { - // the user has already selected this choice - // so we don't want to record it again - return false; - } - - final bool isCorrect = multipleChoiceContent!.isCorrect(choice); - - // 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: choiceConstruct, - target: practiceTarget, - text: choice, - score: isCorrect ? 1 : 0, - ); - - return isCorrect; - } - - bool onMatch( - PangeaToken token, - PracticeChoice choice, - ) { - // the user has already selected this choice - // so we don't want to record it again - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - token.vocabConstructID, - choice.choiceContent, - )) { - return false; - } - - bool isCorrect = false; - if (multipleChoiceContent != null) { - isCorrect = multipleChoiceContent!.answers.any( - (answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(), - ); - } else { - // we check to see if it's in the list of acceptable answers - // rather than if the vocabForm is the same because an emoji - // could be in multiple constructs so there could be multiple answers - final answers = matchContent!.matchInfo[token.vocabForm]; - debugger(when: answers == null && kDebugMode); - isCorrect = answers!.contains(choice.choiceContent); - } - - // NOTE: the response is associated with the contructId of the selected token, not the choice - // example: the user selects the word "cat" to match with the emoji 🐶 - // the response is associated with incorrect word "cat", not the word "dog" - practiceTarget.record.addResponse( - cId: token.vocabConstructID, - target: practiceTarget, - text: choice.choiceContent, - score: isCorrect ? 1 : 0, - ); - - return isCorrect; - } - factory PracticeActivityModel.fromJson(Map json) { - // moving from multiple_choice to content as the key - // this is to make the model more generic - // here for backward compatibility - final Map? contentMap = - (json['content'] ?? json["multiple_choice"]) as Map?; - - if (contentMap == null) { - Sentry.addBreadcrumb( - Breadcrumb(data: {"json": json}), - ); - throw ("content is null in PracticeActivityModel.fromJson"); - } - if (json['lang_code'] is! String) { Sentry.addBreadcrumb( Breadcrumb(data: {"json": json}), @@ -203,58 +45,370 @@ class PracticeActivityModel { throw ("tgt_constructs is not a list in PracticeActivityModel.fromJson"); } - return PracticeActivityModel( - langCode: json['lang_code'] as String, - activityType: ActivityTypeEnum.fromString(json['activity_type']), - multipleChoiceContent: json['content'] != null - ? MultipleChoiceActivity.fromJson(contentMap) - : null, - targetTokens: (json['target_tokens'] as List) - .map((e) => PangeaToken.fromJson(e as Map)) - .toList(), - matchContent: json['match_content'] != null - ? PracticeMatchActivity.fromJson(contentMap) - : null, - morphFeature: json['morph_feature'] != null - ? MorphFeaturesEnumExtension.fromString( - json['morph_feature'] as String, - ) - : null, - ); + final type = ActivityTypeEnum.fromString(json['activity_type']); + + final morph = json['morph_feature'] != null + ? MorphFeaturesEnumExtension.fromString( + json['morph_feature'] as String, + ) + : null; + + final tokens = (json['target_tokens'] as List) + .map((e) => PangeaToken.fromJson(e as Map)) + .toList(); + + final langCode = json['lang_code'] as String; + + final multipleChoiceContent = json['content'] != null + ? MultipleChoiceActivity.fromJson( + json['content'] as Map, + ) + : null; + + final matchContent = json['match_content'] != null + ? PracticeMatchActivity.fromJson( + json['match_content'] as Map, + ) + : null; + + switch (type) { + case ActivityTypeEnum.grammarCategory: + assert( + morph != null, + "morphFeature is null in PracticeActivityModel.fromJson for grammarCategory", + ); + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarCategory", + ); + return MorphCategoryPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + morphFeature: morph!, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.lemmaAudio: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaAudio", + ); + return VocabAudioPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.lemmaMeaning: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaMeaning", + ); + return VocabMeaningPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.emoji: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for emoji", + ); + return EmojiPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + matchContent: matchContent!, + ); + case ActivityTypeEnum.lemmaId: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaId", + ); + return LemmaPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.wordMeaning: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for wordMeaning", + ); + return LemmaMeaningPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + matchContent: matchContent!, + ); + case ActivityTypeEnum.morphId: + assert( + morph != null, + "morphFeature is null in PracticeActivityModel.fromJson for morphId", + ); + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for morphId", + ); + return MorphMatchPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + morphFeature: morph!, + multipleChoiceContent: multipleChoiceContent!, + ); + case ActivityTypeEnum.wordFocusListening: + assert( + matchContent != null, + "matchContent is null in PracticeActivityModel.fromJson for wordFocusListening", + ); + return WordListeningPracticeActivityModel( + langCode: langCode, + targetTokens: tokens, + matchContent: matchContent!, + ); + default: + throw ("Unsupported activity type in PracticeActivityModel.fromJson: $type"); + } } Map toJson() { return { 'lang_code': langCode, 'activity_type': activityType.name, - 'content': multipleChoiceContent?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'match_content': matchContent?.toJson(), - 'morph_feature': morphFeature?.name, }; } +} - // override operator == and hashCode - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; +sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel { + final MultipleChoiceActivity multipleChoiceContent; - return other is PracticeActivityModel && - const ListEquality().equals(other.targetTokens, targetTokens) && - other.langCode == langCode && - other.activityType == activityType && - other.multipleChoiceContent == multipleChoiceContent && - other.matchContent == matchContent && - other.morphFeature == morphFeature; + MultipleChoicePracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.activityType, + required this.multipleChoiceContent, + }); + + bool onMultipleChoiceSelect( + ConstructIdentifier choiceConstruct, + String choice, + ) { + if (practiceTarget.isComplete || + practiceTarget.record.alreadyHasMatchResponse( + choiceConstruct, + choice, + )) { + // the user has already selected this choice + // so we don't want to record it again + return false; + } + + final bool isCorrect = multipleChoiceContent.isCorrect(choice); + practiceTarget.record.addResponse( + cId: choiceConstruct, + target: practiceTarget, + text: choice, + score: isCorrect ? 1 : 0, + ); + return isCorrect; + } + + OneConstructUse constructUse(String choiceContent) { + final correct = multipleChoiceContent.isCorrect(choiceContent); + final useType = + correct ? activityType.correctUse : activityType.incorrectUse; + + return OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: targetTokens.first.pos, + lemma: targetTokens.first.lemma.text, + form: targetTokens.first.lemma.text, + xp: useType.pointValue, + ); } @override - int get hashCode { - return const ListEquality().hash(targetTokens) ^ - langCode.hashCode ^ - activityType.hashCode ^ - multipleChoiceContent.hashCode ^ - matchContent.hashCode ^ - morphFeature.hashCode; + Map toJson() { + final json = super.toJson(); + json['content'] = multipleChoiceContent.toJson(); + return json; } } + +sealed class MatchPracticeActivityModel extends PracticeActivityModel { + final PracticeMatchActivity matchContent; + + MatchPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.activityType, + required this.matchContent, + }); + + bool onMatch( + PangeaToken token, + PracticeChoice choice, + ) { + // the user has already selected this choice + // so we don't want to record it again + if (practiceTarget.isComplete || + practiceTarget.record.alreadyHasMatchResponse( + token.vocabConstructID, + choice.choiceContent, + )) { + return false; + } + + final answers = matchContent.matchInfo[token.vocabForm]; + final isCorrect = answers!.contains(choice.choiceContent); + practiceTarget.record.addResponse( + cId: token.vocabConstructID, + target: practiceTarget, + text: choice.choiceContent, + score: isCorrect ? 1 : 0, + ); + + return isCorrect; + } + + @override + Map toJson() { + final json = super.toJson(); + json['match_content'] = matchContent.toJson(); + return json; + } +} + +sealed class MorphPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + final MorphFeaturesEnum morphFeature; + + MorphPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.activityType, + required super.multipleChoiceContent, + required this.morphFeature, + }); + + @override + PracticeTarget get practiceTarget => PracticeTarget( + tokens: targetTokens, + activityType: activityType, + morphFeature: morphFeature, + ); + + @override + Map toJson() { + final json = super.toJson(); + json['morph_feature'] = morphFeature.name; + return json; + } +} + +class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { + MorphCategoryPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.morphFeature, + required super.multipleChoiceContent, + }) : super( + activityType: ActivityTypeEnum.grammarCategory, + ); + + @override + OneConstructUse constructUse(String choiceContent) { + final correct = multipleChoiceContent.isCorrect(choiceContent); + final useType = + correct ? activityType.correctUse : activityType.incorrectUse; + final tag = targetTokens.first.getMorphTag(morphFeature)!; + + return OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.morph, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: morphFeature.name, + lemma: tag, + form: targetTokens.first.lemma.form, + xp: useType.pointValue, + ); + } +} + +class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel { + MorphMatchPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.morphFeature, + required super.multipleChoiceContent, + }) : super( + activityType: ActivityTypeEnum.morphId, + ); +} + +class VocabAudioPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + VocabAudioPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.multipleChoiceContent, + }) : super( + activityType: ActivityTypeEnum.lemmaAudio, + ); +} + +class VocabMeaningPracticeActivityModel + extends MultipleChoicePracticeActivityModel { + VocabMeaningPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.multipleChoiceContent, + }) : super( + activityType: ActivityTypeEnum.lemmaMeaning, + ); +} + +class LemmaPracticeActivityModel extends MultipleChoicePracticeActivityModel { + LemmaPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.multipleChoiceContent, + }) : super( + activityType: ActivityTypeEnum.lemmaId, + ); +} + +class EmojiPracticeActivityModel extends MatchPracticeActivityModel { + EmojiPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.matchContent, + }) : super( + activityType: ActivityTypeEnum.emoji, + ); +} + +class LemmaMeaningPracticeActivityModel extends MatchPracticeActivityModel { + LemmaMeaningPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.matchContent, + }) : super( + activityType: ActivityTypeEnum.wordMeaning, + ); +} + +class WordListeningPracticeActivityModel extends MatchPracticeActivityModel { + WordListeningPracticeActivityModel({ + required super.targetTokens, + required super.langCode, + required super.matchContent, + }) : super( + activityType: ActivityTypeEnum.wordFocusListening, + ); +} diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 00bfcd671..a780112e0 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -164,4 +164,13 @@ class PracticeTarget { } return false; } + + String promptText() { + switch (activityType) { + case ActivityTypeEnum.grammarCategory: + return "${tokens.first.vocabConstructID.lemma}: ${morphFeature!.name}"; + default: + return tokens.first.vocabConstructID.lemma; + } + } } diff --git a/lib/pangea/practice_activities/word_focus_listening_generator.dart b/lib/pangea/practice_activities/word_focus_listening_generator.dart index 3d03a37ba..80878a0e6 100644 --- a/lib/pangea/practice_activities/word_focus_listening_generator.dart +++ b/lib/pangea/practice_activities/word_focus_listening_generator.dart @@ -1,5 +1,4 @@ import 'package:fluffychat/pangea/constructs/construct_form.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/pangea/practice_activities/practice_match.dart'; @@ -15,8 +14,7 @@ class WordFocusListeningGenerator { } return MessageActivityResponse( - activity: PracticeActivityModel( - activityType: ActivityTypeEnum.wordFocusListening, + activity: WordListeningPracticeActivityModel( targetTokens: req.targetTokens, langCode: req.userL2, matchContent: PracticeMatchActivity( diff --git a/lib/pangea/toolbar/message_practice/message_morph_choice.dart b/lib/pangea/toolbar/message_practice/message_morph_choice.dart index 99d025a4e..12ae15dc6 100644 --- a/lib/pangea/toolbar/message_practice/message_morph_choice.dart +++ b/lib/pangea/toolbar/message_practice/message_morph_choice.dart @@ -30,7 +30,7 @@ const int numberOfMorphDistractors = 3; class MessageMorphInputBarContent extends StatefulWidget { final PracticeController controller; - final PracticeActivityModel activity; + final MorphPracticeActivityModel activity; final PangeaToken? selectedToken; final double maxWidth; @@ -52,7 +52,7 @@ class MessageMorphInputBarContentState String? selectedTag; PangeaToken get token => widget.activity.targetTokens.first; - MorphFeaturesEnum get morph => widget.activity.morphFeature!; + MorphFeaturesEnum get morph => widget.activity.morphFeature; @override void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) { @@ -114,7 +114,7 @@ class MessageMorphInputBarContentState runAlignment: WrapAlignment.center, spacing: spacing, runSpacing: spacing, - children: widget.activity.multipleChoiceContent!.choices.mapIndexed( + children: widget.activity.multipleChoiceContent.choices.mapIndexed( (index, choice) { final wasCorrect = widget.activity.practiceTarget.wasCorrectChoice(choice); @@ -137,7 +137,7 @@ class MessageMorphInputBarContentState form: ConstructForm( cId: widget.activity.targetTokens.first .morphIdByFeature( - widget.activity.morphFeature!, + widget.activity.morphFeature, )!, form: token.text.content, ), diff --git a/lib/pangea/toolbar/message_practice/practice_activity_card.dart b/lib/pangea/toolbar/message_practice/practice_activity_card.dart index 4a6eb7ff0..8561ea27a 100644 --- a/lib/pangea/toolbar/message_practice/practice_activity_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_activity_card.dart @@ -98,17 +98,20 @@ class PracticeActivityCardState extends State { AsyncError() => CardErrorWidget( L10n.of(context).errorFetchingActivity, ), - AsyncLoaded() => state.value.multipleChoiceContent != null - ? MessageMorphInputBarContent( + AsyncLoaded() => switch (state.value) { + MultipleChoicePracticeActivityModel() => + MessageMorphInputBarContent( controller: widget.controller, - activity: state.value, + activity: state.value as MorphPracticeActivityModel, selectedToken: widget.selectedToken, maxWidth: widget.maxWidth, - ) - : MatchActivityCard( - currentActivity: state.value, + ), + MatchPracticeActivityModel() => MatchActivityCard( + currentActivity: + state.value as MatchPracticeActivityModel, controller: widget.controller, ), + }, _ => const SizedBox.shrink(), }, ], diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index d672f415b..99d56cd1f 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -35,8 +35,6 @@ class PracticeController with ChangeNotifier { MorphSelection? selectedMorph; PracticeChoice? selectedChoice; - PracticeActivityModel? get activity => _activity; - PracticeSelection? practiceSelection; bool get isTotallyDone => @@ -65,12 +63,11 @@ class PracticeController with ChangeNotifier { return target == null; } - return target == null || - target.isCompleteByToken( - token, - _activity?.morphFeature, - ) == - true; + final morph = _activity is MorphPracticeActivityModel + ? (_activity as MorphPracticeActivityModel).morphFeature + : null; + + return target == null || target.isCompleteByToken(token, morph) == true; } bool get showChoiceShimmer { @@ -151,11 +148,13 @@ class PracticeController with ChangeNotifier { void onMatch(PangeaToken token, PracticeChoice choice) { if (_activity == null) return; - - final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId - ? _activity! - .onMultipleChoiceSelect(choice.form.cId, choice.choiceContent) - : _activity!.onMatch(token, choice); + final isCorrect = switch (_activity!) { + MultipleChoicePracticeActivityModel() => + (_activity as MultipleChoicePracticeActivityModel) + .onMultipleChoiceSelect(choice.form.cId, choice.choiceContent), + MatchPracticeActivityModel() => + (_activity as MatchPracticeActivityModel).onMatch(token, choice), + }; final targetId = "message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}"; diff --git a/lib/pangea/toolbar/message_practice/practice_match_card.dart b/lib/pangea/toolbar/message_practice/practice_match_card.dart index 317be572a..1b7ae8d1a 100644 --- a/lib/pangea/toolbar/message_practice/practice_match_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_match_card.dart @@ -16,7 +16,7 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.d import 'package:fluffychat/pangea/toolbar/message_practice/practice_match_item.dart'; class MatchActivityCard extends StatelessWidget { - final PracticeActivityModel currentActivity; + final MatchPracticeActivityModel currentActivity; final PracticeController controller; const MatchActivityCard({ @@ -25,8 +25,6 @@ class MatchActivityCard extends StatelessWidget { required this.controller, }); - PracticeActivityModel get activity => currentActivity; - ActivityTypeEnum get activityType => currentActivity.activityType; Widget choiceDisplayContent( @@ -83,7 +81,7 @@ class MatchActivityCard extends StatelessWidget { alignment: WrapAlignment.center, spacing: 4.0, runSpacing: 4.0, - children: activity.matchContent!.choices.map( + children: currentActivity.matchContent.choices.map( (PracticeChoice cf) { final bool? wasCorrect = currentActivity.practiceTarget.wasCorrectMatch(cf); From 45c31afc2b68eba0a7ecf2961c546d1ea91b0561 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 10:36:31 -0500 Subject: [PATCH 3/7] display grammar copy --- .../analytics_practice_page.dart | 9 +++- .../analytics_practice_session_repo.dart | 1 + .../analytics_practice_view.dart | 16 ++++++- .../choice_cards/grammar_choice_card.dart | 48 +++++++++++++++++++ .../morph_category_activity_generator.dart | 6 +-- .../practice_activities/practice_target.dart | 9 +++- 6 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 250a267d9..d85725f48 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -163,6 +163,9 @@ class AnalyticsPracticeState extends State } String getChoiceText(PracticeTarget target, String choiceId) { + if (widget.type == ConstructTypeEnum.morph) { + return choiceId; + } if (_choiceTexts.containsKey(target) && _choiceTexts[target]!.containsKey(choiceId)) { return _choiceTexts[target]![choiceId]!; @@ -171,8 +174,10 @@ class AnalyticsPracticeState extends State return cId?.lemma ?? choiceId; } - String? getChoiceEmoji(PracticeTarget target, String choiceId) => - _choiceEmojis[target]?[choiceId]; + String? getChoiceEmoji(PracticeTarget target, String choiceId) { + if (widget.type == ConstructTypeEnum.morph) return null; + return _choiceEmojis[target]?[choiceId]; + } String choiceTargetId(String choiceId) => '${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}'; diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index ecc97ef2a..27c4d5700 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -132,6 +132,7 @@ class AnalyticsPracticeSessionRepo { break; } + if (use.lemma.isEmpty) continue; final form = use.form; if (seenForms.contains(form) || form == null) { continue; diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index bb6cf5716..3e9379d26 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dar import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart'; @@ -122,7 +123,7 @@ class _AnalyticsActivityView extends StatelessWidget { valueListenable: controller.activityTarget, builder: (context, target, __) => target != null ? Text( - target.promptText(), + target.promptText(context), textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -335,6 +336,19 @@ class _ChoiceCard extends StatelessWidget { height: cardHeight, ); + case ActivityTypeEnum.grammarCategory: + return GrammarChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_grammar_$choiceId', + ), + choiceId: choiceId, + targetId: targetId, + feature: (activity as MorphPracticeActivityModel).morphFeature, + tag: choiceText, + onPressed: onPressed, + isCorrect: isCorrect, + ); + default: return GameChoiceCard( key: ValueKey( diff --git a/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart new file mode 100644 index 000000000..c1299b611 --- /dev/null +++ b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; + +/// Choice card for meaning activity with emoji, and alt text on flip +class GrammarChoiceCard extends StatelessWidget { + final String choiceId; + final String targetId; + + final MorphFeaturesEnum feature; + final String tag; + + final VoidCallback onPressed; + final bool isCorrect; + final double height; + + const GrammarChoiceCard({ + required this.choiceId, + required this.targetId, + required this.feature, + required this.tag, + required this.onPressed, + required this.isCorrect, + this.height = 72.0, + super.key, + }); + + @override + Widget build(BuildContext context) { + final copy = getGrammarCopy( + category: feature.name, + lemma: tag, + context: context, + ) ?? + tag; + + return GameChoiceCard( + shouldFlip: false, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + height: height, + child: Text(copy), + ); + } +} diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart index d9020b7b5..adae7a3ea 100644 --- a/lib/pangea/analytics_practice/morph_category_activity_generator.dart +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -45,9 +45,9 @@ class MorphCategoryActivityGenerator { ) .toList(); - possibleDistractors.shuffle(); - final choices = possibleDistractors.take(3).toSet(); + final choices = possibleDistractors.take(3).toList(); choices.add(morphTag); + choices.shuffle(); return MessageActivityResponse( activity: MorphCategoryPracticeActivityModel( @@ -55,7 +55,7 @@ class MorphCategoryActivityGenerator { langCode: req.userL2, morphFeature: feature, multipleChoiceContent: MultipleChoiceActivity( - choices: choices, + choices: choices.toSet(), answers: {morphTag}, ), ), diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index a780112e0..465535591 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -1,9 +1,11 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -165,10 +167,13 @@ class PracticeTarget { return false; } - String promptText() { + String promptText(BuildContext context) { switch (activityType) { case ActivityTypeEnum.grammarCategory: - return "${tokens.first.vocabConstructID.lemma}: ${morphFeature!.name}"; + return L10n.of(context).whatIsTheMorphTag( + morphFeature!.getDisplayCopy(context), + tokens.first.text.content, + ); default: return tokens.first.vocabConstructID.lemma; } From af92158fa10f1541dac5237a6a9ada000373bf68 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 12:47:44 -0500 Subject: [PATCH 4/7] update activity models to reduce duplicate data --- .../analytics_practice_page.dart | 63 +++--- .../analytics_practice_session_model.dart | 4 +- .../analytics_practice_view.dart | 8 +- .../morph_category_activity_generator.dart | 8 +- .../vocab_audio_activity_generator.dart | 4 +- .../vocab_meaning_activity_generator.dart | 4 +- .../activity_type_enum.dart | 25 +-- .../emoji_activity_generator.dart | 6 +- .../lemma_activity_generator.dart | 6 +- .../lemma_meaning_activity_generator.dart | 6 +- .../message_activity_request.dart | 53 ++--- .../morph_activity_generator.dart | 10 +- .../practice_activity_model.dart | 183 +++++++----------- .../practice_generation_repo.dart | 2 +- .../practice_activities/practice_target.dart | 83 -------- .../word_focus_listening_generator.dart | 6 +- .../layout/message_selection_positioner.dart | 2 +- .../message_morph_choice.dart | 8 +- .../message_practice/practice_controller.dart | 102 +++++++--- .../message_practice/practice_match_card.dart | 24 +-- .../practice_record_controller.dart | 108 +++++++++++ .../reading_assistance_input_bar.dart | 4 +- .../token_practice_button.dart | 17 +- 23 files changed, 353 insertions(+), 383 deletions(-) create mode 100644 lib/pangea/toolbar/message_practice/practice_record_controller.dart diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index d85725f48..e9d83baca 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -17,12 +17,11 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dar import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.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/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'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -74,8 +73,8 @@ class AnalyticsPracticeState extends State final ValueNotifier progressNotifier = ValueNotifier(0.0); - final Map> _choiceTexts = {}; - final Map> _choiceEmojis = {}; + final Map> _choiceTexts = {}; + final Map> _choiceEmojis = {}; StreamSubscription? _languageStreamSubscription; @@ -120,16 +119,16 @@ class AnalyticsPracticeState extends State Matrix.of(context).analyticsDataService; List filteredChoices( - PracticeTarget target, - MultipleChoiceActivity activity, + MultipleChoicePracticeActivityModel activity, ) { - final choices = activity.choices.toList(); - final answer = activity.answers.first; + final content = activity.multipleChoiceContent; + final choices = content.choices.toList(); + final answer = content.answers.first; final filtered = []; final seenTexts = {}; for (final id in choices) { - final text = getChoiceText(target, id); + final text = getChoiceText(activity.storageKey, id); if (seenTexts.contains(text)) { if (id != answer) { @@ -143,7 +142,7 @@ class AnalyticsPracticeState extends State filtered[index] = PracticeChoice( choiceId: id, choiceText: text, - choiceEmoji: getChoiceEmoji(target, id), + choiceEmoji: getChoiceEmoji(activity.storageKey, id), ); } continue; @@ -154,7 +153,7 @@ class AnalyticsPracticeState extends State PracticeChoice( choiceId: id, choiceText: text, - choiceEmoji: getChoiceEmoji(target, id), + choiceEmoji: getChoiceEmoji(activity.storageKey, id), ), ); } @@ -162,21 +161,21 @@ class AnalyticsPracticeState extends State return filtered; } - String getChoiceText(PracticeTarget target, String choiceId) { + String getChoiceText(String key, String choiceId) { if (widget.type == ConstructTypeEnum.morph) { return choiceId; } - if (_choiceTexts.containsKey(target) && - _choiceTexts[target]!.containsKey(choiceId)) { - return _choiceTexts[target]![choiceId]!; + if (_choiceTexts.containsKey(key) && + _choiceTexts[key]!.containsKey(choiceId)) { + return _choiceTexts[key]![choiceId]!; } final cId = ConstructIdentifier.fromString(choiceId); return cId?.lemma ?? choiceId; } - String? getChoiceEmoji(PracticeTarget target, String choiceId) { + String? getChoiceEmoji(String key, String choiceId) { if (widget.type == ConstructTypeEnum.morph) return null; - return _choiceEmojis[target]?[choiceId]; + return _choiceEmojis[key]?[choiceId]; } String choiceTargetId(String choiceId) => @@ -297,7 +296,7 @@ class AnalyticsPracticeState extends State final res = await _fetchActivity(req); if (!mounted) return; - activityTarget.value = req.practiceTarget; + activityTarget.value = req.target; activityState.value = AsyncState.loaded(res); } catch (e) { if (!mounted) return; @@ -311,12 +310,7 @@ class AnalyticsPracticeState extends State Future _fillActivityQueue(List requests) async { for (final request in requests) { final completer = Completer(); - _queue.add( - MapEntry( - request.practiceTarget, - completer, - ), - ); + _queue.add(MapEntry(request.target, completer)); try { final res = await _fetchActivity(request); @@ -346,16 +340,16 @@ class AnalyticsPracticeState extends State final activityModel = result.result as MultipleChoicePracticeActivityModel; // Prefetch lemma info for meaning activities before marking ready - if (activityModel.activityType == ActivityTypeEnum.lemmaMeaning) { + if (activityModel is VocabMeaningPracticeActivityModel) { final choices = activityModel.multipleChoiceContent.choices.toList(); - await _fetchLemmaInfo(activityModel.practiceTarget, choices); + await _fetchLemmaInfo(activityModel.storageKey, choices); } return activityModel; } Future _fetchLemmaInfo( - PracticeTarget target, + String requestKey, List choiceIds, ) async { final texts = {}; @@ -375,22 +369,25 @@ class AnalyticsPracticeState extends State emojis[id] = res.result!.emoji.firstOrNull; } - _choiceTexts.putIfAbsent(target, () => {}); - _choiceEmojis.putIfAbsent(target, () => {}); + _choiceTexts.putIfAbsent(requestKey, () => {}); + _choiceEmojis.putIfAbsent(requestKey, () => {}); - _choiceTexts[target]!.addAll(texts); - _choiceEmojis[target]!.addAll(emojis); + _choiceTexts[requestKey]!.addAll(texts); + _choiceEmojis[requestKey]!.addAll(emojis); } Future onSelectChoice( - ConstructIdentifier choiceConstruct, String choiceContent, ) async { if (_currentActivity == null) return; final activity = _currentActivity!; // Update activity record - activity.onMultipleChoiceSelect(choiceConstruct, choiceContent); + PracticeRecordController.onSelectChoice( + choiceContent, + activity.tokens.first, + activity, + ); final use = activity.constructUse(choiceContent); _sessionLoader.value!.submitAnswer(use); diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index 9c27769e9..2b35e45e9 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -38,9 +38,7 @@ class AnalyticsPracticeSessionModel { userL1: userL1, userL2: userL2, activityQualityFeedback: null, - targetTokens: target.tokens, - targetType: target.activityType, - targetMorphFeature: target.morphFeature, + target: target, ); }).toList(); } diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 3e9379d26..8dfc22999 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -236,10 +236,7 @@ class _ActivityChoicesWidget extends StatelessWidget { AsyncLoaded(:final value) => LayoutBuilder( builder: (context, constraints) { - final choices = controller.filteredChoices( - value.practiceTarget, - value.multipleChoiceContent, - ); + final choices = controller.filteredChoices(value); final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0); final cardHeight = (constrainedHeight / (choices.length + 1)) @@ -258,7 +255,6 @@ class _ActivityChoicesWidget extends StatelessWidget { controller.choiceTargetId(choice.choiceId), choiceId: choice.choiceId, onPressed: () => controller.onSelectChoice( - value.targetTokens.first.vocabConstructID, choice.choiceId, ), cardHeight: cardHeight, @@ -307,7 +303,7 @@ class _ChoiceCard extends StatelessWidget { Widget build(BuildContext context) { final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId); final activityType = activity.activityType; - final constructId = activity.targetTokens.first.vocabConstructID; + final constructId = activity.tokens.first.vocabConstructID; switch (activity.activityType) { case ActivityTypeEnum.lemmaMeaning: diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart index adae7a3ea..545e466ba 100644 --- a/lib/pangea/analytics_practice/morph_category_activity_generator.dart +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -11,14 +11,14 @@ class MorphCategoryActivityGenerator { static Future get( MessageActivityRequest req, ) async { - if (req.targetMorphFeature == null) { + if (req.target.morphFeature == null) { throw ArgumentError( "MorphCategoryActivityGenerator requires a targetMorphFeature", ); } - final feature = req.targetMorphFeature!; - final morphTag = req.targetTokens.first.getMorphTag(feature); + final feature = req.target.morphFeature!; + final morphTag = req.target.tokens.first.getMorphTag(feature); if (morphTag == null) { throw ArgumentError( "Token does not have the specified morph feature", @@ -51,7 +51,7 @@ class MorphCategoryActivityGenerator { return MessageActivityResponse( activity: MorphCategoryPracticeActivityModel( - targetTokens: [req.targetTokens.first], + tokens: req.target.tokens, langCode: req.userL2, morphFeature: feature, multipleChoiceContent: MultipleChoiceActivity( diff --git a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index bcbbbb721..7b2954f51 100644 --- a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -7,7 +7,7 @@ class VocabAudioActivityGenerator { static Future get( MessageActivityRequest req, ) async { - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await LemmaActivityGenerator.lemmaActivityDistractors(token); @@ -16,7 +16,7 @@ class VocabAudioActivityGenerator { return MessageActivityResponse( activity: VocabAudioPracticeActivityModel( - targetTokens: [token], + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: choicesList.toSet(), diff --git a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart index cc258a76b..7acc77b0d 100644 --- a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart @@ -7,7 +7,7 @@ class VocabMeaningActivityGenerator { static Future get( MessageActivityRequest req, ) async { - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await LemmaActivityGenerator.lemmaActivityDistractors(token); @@ -19,7 +19,7 @@ class VocabMeaningActivityGenerator { return MessageActivityResponse( activity: VocabMeaningPracticeActivityModel( - targetTokens: [token], + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: constructIdChoices, diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 42696c226..02a9e9c91 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -228,12 +228,12 @@ enum ActivityTypeEnum { ActivityTypeEnum.morphId, ]; - static List get vocabPracticeTypes => [ + static List get _vocabPracticeTypes => [ ActivityTypeEnum.lemmaMeaning, // ActivityTypeEnum.lemmaAudio, ]; - static List get grammarPracticeTypes => [ + static List get _grammarPracticeTypes => [ ActivityTypeEnum.grammarCategory, ]; @@ -242,26 +242,9 @@ enum ActivityTypeEnum { ) { switch (constructType) { case ConstructTypeEnum.vocab: - return vocabPracticeTypes; + return _vocabPracticeTypes; case ConstructTypeEnum.morph: - return grammarPracticeTypes; - } - } - - ConstructTypeEnum get constructType { - switch (this) { - case ActivityTypeEnum.wordMeaning: - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.hiddenWordListening: - case ActivityTypeEnum.lemmaId: - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.messageMeaning: - case ActivityTypeEnum.lemmaMeaning: - case ActivityTypeEnum.lemmaAudio: - return ConstructTypeEnum.vocab; - case ActivityTypeEnum.morphId: - case ActivityTypeEnum.grammarCategory: - return ConstructTypeEnum.morph; + return _grammarPracticeTypes; } } } diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 817162a8b..5b8881a5a 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -12,7 +12,7 @@ class EmojiActivityGenerator { MessageActivityRequest req, { required Map messageInfo, }) async { - if (req.targetTokens.length <= 1) { + if (req.target.tokens.length <= 1) { throw Exception("Emoji activity requires at least 2 tokens"); } @@ -27,7 +27,7 @@ class EmojiActivityGenerator { final List missingEmojis = []; final List usedEmojis = []; - for (final token in req.targetTokens) { + for (final token in req.target.tokens) { final userSavedEmoji = token.vocabConstructID.userSetEmoji; if (userSavedEmoji != null && !usedEmojis.contains(userSavedEmoji)) { matchInfo[token.vocabForm] = [userSavedEmoji]; @@ -65,7 +65,7 @@ class EmojiActivityGenerator { return MessageActivityResponse( activity: EmojiPracticeActivityModel( - targetTokens: req.targetTokens, + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: matchInfo, diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index 198c3161a..83ff095de 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -14,15 +14,15 @@ class LemmaActivityGenerator { static Future get( MessageActivityRequest req, ) async { - debugger(when: kDebugMode && req.targetTokens.length != 1); + debugger(when: kDebugMode && req.target.tokens.length != 1); - final token = req.targetTokens.first; + final token = req.target.tokens.first; final choices = await lemmaActivityDistractors(token); // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( activity: LemmaPracticeActivityModel( - targetTokens: [token], + tokens: req.target.tokens, langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( choices: choices.map((c) => c.lemma).toSet(), diff --git a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index 981668c20..3e32611b0 100644 --- a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -15,7 +15,7 @@ class LemmaMeaningActivityGenerator { required Map messageInfo, }) async { final List>> lemmaInfoFutures = req - .targetTokens + .target.tokens .map((token) => token.vocabConstructID.getLemmaInfo(messageInfo)) .toList(); @@ -27,13 +27,13 @@ class LemmaMeaningActivityGenerator { } final Map> matchInfo = Map.fromIterables( - req.targetTokens.map((token) => token.vocabForm), + req.target.tokens.map((token) => token.vocabForm), lemmaInfos.map((lemmaInfo) => [lemmaInfo.asValue!.value.meaning]), ); return MessageActivityResponse( activity: LemmaMeaningPracticeActivityModel( - targetTokens: req.targetTokens, + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: matchInfo, diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 824cad45a..fef5c5243 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -1,9 +1,5 @@ -import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.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_target.dart'; @@ -42,70 +38,49 @@ class ActivityQualityFeedback { class MessageActivityRequest { final String userL1; final String userL2; - - final List targetTokens; - final ActivityTypeEnum targetType; - final MorphFeaturesEnum? targetMorphFeature; - + final PracticeTarget target; final ActivityQualityFeedback? activityQualityFeedback; MessageActivityRequest({ required this.userL1, required this.userL2, required this.activityQualityFeedback, - required this.targetTokens, - required this.targetType, - required this.targetMorphFeature, + required this.target, }) { - if (targetTokens.isEmpty) { + if (target.tokens.isEmpty) { throw Exception('Target tokens must not be empty'); } } - String get activityText { - switch (targetType) { - case ActivityTypeEnum.grammarCategory: - return "${targetTokens.first.vocabConstructID.lemma}: ${targetMorphFeature!.name}"; - default: - return targetTokens.first.vocabConstructID.lemma; - } - } - Map toJson() { return { 'user_l1': userL1, 'user_l2': userL2, 'activity_quality_feedback': activityQualityFeedback?.toJson(), - 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), - 'target_type': targetType.name, - 'target_morph_feature': targetMorphFeature, + 'target_tokens': target.tokens.map((e) => e.toJson()).toList(), + 'target_type': target.activityType.name, + 'target_morph_feature': target.morphFeature, }; } - PracticeTarget get practiceTarget => PracticeTarget( - activityType: targetType, - tokens: targetTokens, - morphFeature: targetMorphFeature, - ); - @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is MessageActivityRequest && - other.targetType == targetType && + other.userL1 == userL1 && + other.userL2 == userL2 && + other.target == target && other.activityQualityFeedback?.feedbackText == - activityQualityFeedback?.feedbackText && - const ListEquality().equals(other.targetTokens, targetTokens) && - other.targetMorphFeature == targetMorphFeature; + activityQualityFeedback?.feedbackText; } @override int get hashCode { - return targetType.hashCode ^ - activityQualityFeedback.hashCode ^ - targetTokens.hashCode ^ - targetMorphFeature.hashCode; + return activityQualityFeedback.hashCode ^ + target.hashCode ^ + userL1.hashCode ^ + userL2.hashCode; } } diff --git a/lib/pangea/practice_activities/morph_activity_generator.dart b/lib/pangea/practice_activities/morph_activity_generator.dart index 434929490..0c2e2bf5b 100644 --- a/lib/pangea/practice_activities/morph_activity_generator.dart +++ b/lib/pangea/practice_activities/morph_activity_generator.dart @@ -17,13 +17,13 @@ class MorphActivityGenerator { static MessageActivityResponse get( MessageActivityRequest req, ) { - debugger(when: kDebugMode && req.targetTokens.length != 1); + debugger(when: kDebugMode && req.target.tokens.length != 1); - debugger(when: kDebugMode && req.targetMorphFeature == null); + debugger(when: kDebugMode && req.target.morphFeature == null); - final PangeaToken token = req.targetTokens.first; + final PangeaToken token = req.target.tokens.first; - final MorphFeaturesEnum morphFeature = req.targetMorphFeature!; + final MorphFeaturesEnum morphFeature = req.target.morphFeature!; final String? morphTag = token.getMorphTag(morphFeature); if (morphTag == null) { @@ -38,7 +38,7 @@ class MorphActivityGenerator { return MessageActivityResponse( activity: MorphMatchPracticeActivityModel( - targetTokens: req.targetTokens, + tokens: req.target.tokens, langCode: req.userL2, morphFeature: morphFeature, multipleChoiceContent: MultipleChoiceActivity( diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index eac79e60a..e9d37f2ba 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -3,31 +3,54 @@ import 'package:sentry_flutter/sentry_flutter.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/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; sealed class PracticeActivityModel { - final List targetTokens; - final ActivityTypeEnum activityType; + final List tokens; final String langCode; const PracticeActivityModel({ - required this.targetTokens, + required this.tokens, required this.langCode, - required this.activityType, }); + String get storageKey => + '${activityType.name}-${tokens.map((e) => e.text.content).join("-")}'; + PracticeTarget get practiceTarget => PracticeTarget( - tokens: targetTokens, activityType: activityType, + tokens: tokens, + morphFeature: this is MorphPracticeActivityModel + ? (this as MorphPracticeActivityModel).morphFeature + : null, ); + ActivityTypeEnum get activityType { + switch (this) { + case MorphCategoryPracticeActivityModel(): + return ActivityTypeEnum.grammarCategory; + case VocabAudioPracticeActivityModel(): + return ActivityTypeEnum.lemmaAudio; + case VocabMeaningPracticeActivityModel(): + return ActivityTypeEnum.lemmaMeaning; + case EmojiPracticeActivityModel(): + return ActivityTypeEnum.emoji; + case LemmaPracticeActivityModel(): + return ActivityTypeEnum.lemmaId; + case LemmaMeaningPracticeActivityModel(): + return ActivityTypeEnum.wordMeaning; + case MorphMatchPracticeActivityModel(): + return ActivityTypeEnum.morphId; + case WordListeningPracticeActivityModel(): + return ActivityTypeEnum.wordFocusListening; + } + } + factory PracticeActivityModel.fromJson(Map json) { if (json['lang_code'] is! String) { Sentry.addBreadcrumb( @@ -83,7 +106,7 @@ sealed class PracticeActivityModel { ); return MorphCategoryPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, morphFeature: morph!, multipleChoiceContent: multipleChoiceContent!, ); @@ -94,7 +117,7 @@ sealed class PracticeActivityModel { ); return VocabAudioPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, multipleChoiceContent: multipleChoiceContent!, ); case ActivityTypeEnum.lemmaMeaning: @@ -104,7 +127,7 @@ sealed class PracticeActivityModel { ); return VocabMeaningPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, multipleChoiceContent: multipleChoiceContent!, ); case ActivityTypeEnum.emoji: @@ -114,7 +137,7 @@ sealed class PracticeActivityModel { ); return EmojiPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, matchContent: matchContent!, ); case ActivityTypeEnum.lemmaId: @@ -124,7 +147,7 @@ sealed class PracticeActivityModel { ); return LemmaPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, multipleChoiceContent: multipleChoiceContent!, ); case ActivityTypeEnum.wordMeaning: @@ -134,7 +157,7 @@ sealed class PracticeActivityModel { ); return LemmaMeaningPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, matchContent: matchContent!, ); case ActivityTypeEnum.morphId: @@ -148,7 +171,7 @@ sealed class PracticeActivityModel { ); return MorphMatchPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, morphFeature: morph!, multipleChoiceContent: multipleChoiceContent!, ); @@ -159,7 +182,7 @@ sealed class PracticeActivityModel { ); return WordListeningPracticeActivityModel( langCode: langCode, - targetTokens: tokens, + tokens: tokens, matchContent: matchContent!, ); default: @@ -171,7 +194,7 @@ sealed class PracticeActivityModel { return { 'lang_code': langCode, 'activity_type': activityType.name, - 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), + 'target_tokens': tokens.map((e) => e.toJson()).toList(), }; } } @@ -180,40 +203,18 @@ sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel { final MultipleChoiceActivity multipleChoiceContent; MultipleChoicePracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, - required super.activityType, required this.multipleChoiceContent, }); - bool onMultipleChoiceSelect( - ConstructIdentifier choiceConstruct, - String choice, - ) { - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - choiceConstruct, - choice, - )) { - // the user has already selected this choice - // so we don't want to record it again - return false; - } - - final bool isCorrect = multipleChoiceContent.isCorrect(choice); - practiceTarget.record.addResponse( - cId: choiceConstruct, - target: practiceTarget, - text: choice, - score: isCorrect ? 1 : 0, - ); - return isCorrect; - } + bool isCorrect(String choice) => multipleChoiceContent.isCorrect(choice); OneConstructUse constructUse(String choiceContent) { final correct = multipleChoiceContent.isCorrect(choiceContent); final useType = correct ? activityType.correctUse : activityType.incorrectUse; + final token = tokens.first; return OneConstructUse( useType: useType, @@ -222,9 +223,9 @@ sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel { roomId: null, timeStamp: DateTime.now(), ), - category: targetTokens.first.pos, - lemma: targetTokens.first.lemma.text, - form: targetTokens.first.lemma.text, + category: token.pos, + lemma: token.lemma.text, + form: token.lemma.text, xp: useType.pointValue, ); } @@ -241,37 +242,16 @@ sealed class MatchPracticeActivityModel extends PracticeActivityModel { final PracticeMatchActivity matchContent; MatchPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, - required super.activityType, required this.matchContent, }); - bool onMatch( + bool isCorrect( PangeaToken token, - PracticeChoice choice, - ) { - // the user has already selected this choice - // so we don't want to record it again - if (practiceTarget.isComplete || - practiceTarget.record.alreadyHasMatchResponse( - token.vocabConstructID, - choice.choiceContent, - )) { - return false; - } - - final answers = matchContent.matchInfo[token.vocabForm]; - final isCorrect = answers!.contains(choice.choiceContent); - practiceTarget.record.addResponse( - cId: token.vocabConstructID, - target: practiceTarget, - text: choice.choiceContent, - score: isCorrect ? 1 : 0, - ); - - return isCorrect; - } + String choice, + ) => + matchContent.matchInfo[token.vocabForm]!.contains(choice); @override Map toJson() { @@ -286,19 +266,15 @@ sealed class MorphPracticeActivityModel final MorphFeaturesEnum morphFeature; MorphPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, - required super.activityType, required super.multipleChoiceContent, required this.morphFeature, }); @override - PracticeTarget get practiceTarget => PracticeTarget( - tokens: targetTokens, - activityType: activityType, - morphFeature: morphFeature, - ); + String get storageKey => + '${activityType.name}-${tokens.map((e) => e.text.content).join("-")}-${morphFeature.name}'; @override Map toJson() { @@ -310,20 +286,19 @@ sealed class MorphPracticeActivityModel class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { MorphCategoryPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.morphFeature, required super.multipleChoiceContent, - }) : super( - activityType: ActivityTypeEnum.grammarCategory, - ); + }); @override OneConstructUse constructUse(String choiceContent) { final correct = multipleChoiceContent.isCorrect(choiceContent); + final token = tokens.first; final useType = correct ? activityType.correctUse : activityType.incorrectUse; - final tag = targetTokens.first.getMorphTag(morphFeature)!; + final tag = token.getMorphTag(morphFeature)!; return OneConstructUse( useType: useType, @@ -334,7 +309,7 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { ), category: morphFeature.name, lemma: tag, - form: targetTokens.first.lemma.form, + form: token.lemma.form, xp: useType.pointValue, ); } @@ -342,73 +317,59 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel { MorphMatchPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.morphFeature, required super.multipleChoiceContent, - }) : super( - activityType: ActivityTypeEnum.morphId, - ); + }); } class VocabAudioPracticeActivityModel extends MultipleChoicePracticeActivityModel { VocabAudioPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.multipleChoiceContent, - }) : super( - activityType: ActivityTypeEnum.lemmaAudio, - ); + }); } class VocabMeaningPracticeActivityModel extends MultipleChoicePracticeActivityModel { VocabMeaningPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.multipleChoiceContent, - }) : super( - activityType: ActivityTypeEnum.lemmaMeaning, - ); + }); } class LemmaPracticeActivityModel extends MultipleChoicePracticeActivityModel { LemmaPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.multipleChoiceContent, - }) : super( - activityType: ActivityTypeEnum.lemmaId, - ); + }); } class EmojiPracticeActivityModel extends MatchPracticeActivityModel { EmojiPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.matchContent, - }) : super( - activityType: ActivityTypeEnum.emoji, - ); + }); } class LemmaMeaningPracticeActivityModel extends MatchPracticeActivityModel { LemmaMeaningPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.matchContent, - }) : super( - activityType: ActivityTypeEnum.wordMeaning, - ); + }); } class WordListeningPracticeActivityModel extends MatchPracticeActivityModel { WordListeningPracticeActivityModel({ - required super.targetTokens, + required super.tokens, required super.langCode, required super.matchContent, - }) : super( - activityType: ActivityTypeEnum.wordFocusListening, - ); + }); } diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 84883cd50..68d3cb821 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -117,7 +117,7 @@ class PracticeRepo { required Map messageInfo, }) async { // some activities we'll get from the server and others we'll generate locally - switch (req.targetType) { + switch (req.target.activityType) { case ActivityTypeEnum.emoji: return EmojiActivityGenerator.get(req, messageInfo: messageInfo); case ActivityTypeEnum.lemmaId: diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 465535591..5e5da4adc 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -1,19 +1,12 @@ -import 'dart:developer'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; /// Picks which tokens to do activities on and what types of activities to do /// Caches result so that we don't have to recompute it @@ -91,82 +84,6 @@ class PracticeTarget { (morphFeature?.name ?? ""); } - PracticeRecord get record => PracticeRecordRepo.get(this); - - bool get isComplete { - if (activityType == ActivityTypeEnum.morphId) { - return record.completeResponses > 0; - } - - return tokens.every( - (t) => record.responses - .any((res) => res.cId == t.vocabConstructID && res.isCorrect), - ); - } - - bool isCompleteByToken(PangeaToken token, [MorphFeaturesEnum? morph]) { - final ConstructIdentifier? cId = - morph == null ? token.vocabConstructID : token.morphIdByFeature(morph); - if (cId == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "isCompleteByToken: cId is null for token ${token.text.content}", - data: { - "t": token.toJson(), - "morph": morph?.name, - }, - ); - return false; - } - - if (activityType == ActivityTypeEnum.morphId) { - return record.responses.any( - (res) => res.cId == token.morphIdByFeature(morph!) && res.isCorrect, - ); - } - - return record.responses.any( - (res) => res.cId == token.vocabConstructID && res.isCorrect, - ); - } - - bool? wasCorrectChoice(String choice) { - for (final response in record.responses) { - if (response.text == choice) { - return response.isCorrect; - } - } - return null; - } - - /// if any of the choices were correct, return true - /// if all of the choices were incorrect, return false - /// if null, it means the user has not yet responded with that choice - bool? wasCorrectMatch(PracticeChoice choice) { - for (final response in record.responses) { - if (response.text == choice.choiceContent && response.isCorrect) { - return true; - } - } - for (final response in record.responses) { - if (response.text == choice.choiceContent) { - return false; - } - } - return null; - } - - bool get hasAnyResponses => record.responses.isNotEmpty; - - bool get hasAnyCorrectChoices { - for (final response in record.responses) { - if (response.isCorrect) { - return true; - } - } - return false; - } - String promptText(BuildContext context) { switch (activityType) { case ActivityTypeEnum.grammarCategory: diff --git a/lib/pangea/practice_activities/word_focus_listening_generator.dart b/lib/pangea/practice_activities/word_focus_listening_generator.dart index 80878a0e6..0511b6fdb 100644 --- a/lib/pangea/practice_activities/word_focus_listening_generator.dart +++ b/lib/pangea/practice_activities/word_focus_listening_generator.dart @@ -7,7 +7,7 @@ class WordFocusListeningGenerator { static MessageActivityResponse get( MessageActivityRequest req, ) { - if (req.targetTokens.length <= 1) { + if (req.target.tokens.length <= 1) { throw Exception( "Word focus listening activity requires at least 2 tokens", ); @@ -15,11 +15,11 @@ class WordFocusListeningGenerator { return MessageActivityResponse( activity: WordListeningPracticeActivityModel( - targetTokens: req.targetTokens, + tokens: req.target.tokens, langCode: req.userL2, matchContent: PracticeMatchActivity( matchInfo: Map.fromEntries( - req.targetTokens.map( + req.target.tokens.map( (token) => MapEntry( ConstructForm( cId: token.vocabConstructID, diff --git a/lib/pangea/toolbar/layout/message_selection_positioner.dart b/lib/pangea/toolbar/layout/message_selection_positioner.dart index a905a1fa1..477a3b5f8 100644 --- a/lib/pangea/toolbar/layout/message_selection_positioner.dart +++ b/lib/pangea/toolbar/layout/message_selection_positioner.dart @@ -482,7 +482,7 @@ class MessageSelectionPositionerState extends State final type = practice.practiceMode.associatedActivityType; final complete = type != null && - practice.isPracticeActivityDone(type); + practice.isPracticeSessionDone(type); if (instruction != null && !complete) { return InstructionsInlineTooltip( diff --git a/lib/pangea/toolbar/message_practice/message_morph_choice.dart b/lib/pangea/toolbar/message_practice/message_morph_choice.dart index 12ae15dc6..458932773 100644 --- a/lib/pangea/toolbar/message_practice/message_morph_choice.dart +++ b/lib/pangea/toolbar/message_practice/message_morph_choice.dart @@ -51,7 +51,7 @@ class MessageMorphInputBarContentState extends State { String? selectedTag; - PangeaToken get token => widget.activity.targetTokens.first; + PangeaToken get token => widget.activity.tokens.first; MorphFeaturesEnum get morph => widget.activity.morphFeature; @override @@ -116,8 +116,7 @@ class MessageMorphInputBarContentState runSpacing: spacing, children: widget.activity.multipleChoiceContent.choices.mapIndexed( (index, choice) { - final wasCorrect = - widget.activity.practiceTarget.wasCorrectChoice(choice); + final wasCorrect = widget.controller.wasCorrectChoice(choice); return ChoiceAnimationWidget( isSelected: selectedTag == choice, @@ -135,8 +134,7 @@ class MessageMorphInputBarContentState PracticeChoice( choiceContent: choice, form: ConstructForm( - cId: widget.activity.targetTokens.first - .morphIdByFeature( + cId: widget.activity.tokens.first.morphIdByFeature( widget.activity.morphFeature, )!, form: token.text.content, diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 99d56cd1f..1f572ada2 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; @@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.da import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/morph_selection.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -37,14 +38,43 @@ class PracticeController with ChangeNotifier { PracticeSelection? practiceSelection; - bool get isTotallyDone => - isPracticeActivityDone(ActivityTypeEnum.emoji) && - isPracticeActivityDone(ActivityTypeEnum.wordMeaning) && - isPracticeActivityDone(ActivityTypeEnum.wordFocusListening) && - isPracticeActivityDone(ActivityTypeEnum.morphId); + bool? wasCorrectMatch(PracticeChoice choice) { + if (_activity == null) return false; + final record = PracticeRecordController.recordByActivity(_activity!); + for (final response in record.responses) { + if (response.text == choice.choiceContent && response.isCorrect) { + return true; + } + } + for (final response in record.responses) { + if (response.text == choice.choiceContent) { + return false; + } + } + return null; + } - bool isPracticeActivityDone(ActivityTypeEnum activityType) => - practiceSelection?.activities(activityType).every((a) => a.isComplete) == + bool? wasCorrectChoice(String choice) { + if (_activity == null) return false; + final record = PracticeRecordController.recordByActivity(_activity!); + for (final response in record.responses) { + if (response.text == choice) { + return response.isCorrect; + } + } + return null; + } + + bool get isTotallyDone => + isPracticeSessionDone(ActivityTypeEnum.emoji) && + isPracticeSessionDone(ActivityTypeEnum.wordMeaning) && + isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) && + isPracticeSessionDone(ActivityTypeEnum.morphId); + + bool isPracticeSessionDone(ActivityTypeEnum activityType) => + practiceSelection + ?.activities(activityType) + .every((a) => PracticeRecordController.isCompleteByTarget(a)) == true; bool isPracticeButtonEmpty(PangeaToken token) { @@ -67,21 +97,38 @@ class PracticeController with ChangeNotifier { ? (_activity as MorphPracticeActivityModel).morphFeature : null; - return target == null || target.isCompleteByToken(token, morph) == true; + return target == null || + PracticeRecordController.isCompleteByToken( + target, + token, + morph, + ); } bool get showChoiceShimmer { if (_activity == null) return false; + final record = PracticeRecordController.recordByActivity(_activity!); - if (_activity!.activityType == ActivityTypeEnum.morphId) { - return selectedMorph != null && - !_activity!.practiceTarget.hasAnyResponses; + if (_activity is MorphMatchPracticeActivityModel) { + return selectedMorph != null && record.responses.isEmpty; } - return selectedChoice == null && - !_activity!.practiceTarget.hasAnyCorrectChoices; + return selectedChoice == null && !record.responses.any((r) => r.isCorrect); } + // PracticeTarget? get _target => _activity != null + // ? PracticeTarget( + // activityType: _activity!.activityType, + // tokens: _activity!.tokens, + // morphFeature: _activity is MorphPracticeActivityModel + // ? (_activity as MorphPracticeActivityModel).morphFeature + // : null, + // ) + // : null; + + // PracticeRecord? get _record => + // _target != null ? PracticeRecordRepo.get(_target!) : null; + Future _fetchPracticeSelection() async { if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return; practiceSelection = await PracticeSelectionRepo.get( @@ -98,9 +145,7 @@ class PracticeController with ChangeNotifier { userL1: MatrixState.pangeaController.userController.userL1!.langCode, userL2: MatrixState.pangeaController.userController.userL2!.langCode, activityQualityFeedback: null, - targetTokens: target.tokens, - targetType: target.activityType, - targetMorphFeature: target.morphFeature, + target: target, ); final result = await PracticeRepo.getPracticeActivity( @@ -148,13 +193,12 @@ class PracticeController with ChangeNotifier { void onMatch(PangeaToken token, PracticeChoice choice) { if (_activity == null) return; - final isCorrect = switch (_activity!) { - MultipleChoicePracticeActivityModel() => - (_activity as MultipleChoicePracticeActivityModel) - .onMultipleChoiceSelect(choice.form.cId, choice.choiceContent), - MatchPracticeActivityModel() => - (_activity as MatchPracticeActivityModel).onMatch(token, choice), - }; + final record = PracticeRecordController.recordByActivity(_activity!); + final isCorrect = PracticeRecordController.onSelectChoice( + choice.choiceContent, + token, + _activity!, + ); final targetId = "message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}"; @@ -163,9 +207,9 @@ class PracticeController with ChangeNotifier { .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 - .useType(_activity!.activityType); + if (_activity is! EmojiPracticeActivityModel || isCorrect) { + final constructUseType = + record.responses.last.useType(_activity!.activityType); final constructs = [ OneConstructUse( @@ -191,14 +235,14 @@ class PracticeController with ChangeNotifier { } if (isCorrect) { - if (_activity!.activityType == ActivityTypeEnum.emoji) { + if (_activity is EmojiPracticeActivityModel) { updateService.setLemmaInfo( choice.form.cId, emoji: choice.choiceContent, ); } - if (_activity!.activityType == ActivityTypeEnum.wordMeaning) { + if (_activity is LemmaMeaningPracticeActivityModel) { updateService.setLemmaInfo( choice.form.cId, meaning: choice.choiceContent, diff --git a/lib/pangea/toolbar/message_practice/practice_match_card.dart b/lib/pangea/toolbar/message_practice/practice_match_card.dart index 1b7ae8d1a..69b8f4a96 100644 --- a/lib/pangea/toolbar/message_practice/practice_match_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_match_card.dart @@ -1,13 +1,9 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/common/widgets/choice_animation.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_choice.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart'; @@ -25,16 +21,16 @@ class MatchActivityCard extends StatelessWidget { required this.controller, }); - ActivityTypeEnum get activityType => currentActivity.activityType; + // ActivityTypeEnum get activityType => currentActivity.activityType; Widget choiceDisplayContent( BuildContext context, String choice, double? fontSize, ) { - switch (activityType) { - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.wordMeaning: + switch (currentActivity) { + case EmojiPracticeActivityModel(): + case LemmaMeaningPracticeActivityModel(): return Padding( padding: const EdgeInsets.all(8), child: Text( @@ -43,7 +39,7 @@ class MatchActivityCard extends StatelessWidget { textAlign: TextAlign.center, ), ); - case ActivityTypeEnum.wordFocusListening: + case WordListeningPracticeActivityModel(): return Padding( padding: const EdgeInsets.all(8), child: Icon( @@ -51,9 +47,6 @@ class MatchActivityCard extends StatelessWidget { size: fontSize, ), ); - default: - debugger(when: kDebugMode); - return const SizedBox(); } } @@ -83,13 +76,12 @@ class MatchActivityCard extends StatelessWidget { runSpacing: 4.0, children: currentActivity.matchContent.choices.map( (PracticeChoice cf) { - final bool? wasCorrect = - currentActivity.practiceTarget.wasCorrectMatch(cf); + final bool? wasCorrect = controller.wasCorrectMatch(cf); return ChoiceAnimationWidget( isSelected: controller.selectedChoice == cf, isCorrect: wasCorrect, child: PracticeMatchItem( - token: currentActivity.practiceTarget.tokens.firstWhereOrNull( + token: currentActivity.tokens.firstWhereOrNull( (t) => t.vocabConstructID == cf.form.cId, ), isSelected: controller.selectedChoice == cf, @@ -98,7 +90,7 @@ class MatchActivityCard extends StatelessWidget { content: choiceDisplayContent(context, cf.choiceContent, fontSize), audioContent: - activityType == ActivityTypeEnum.wordFocusListening + currentActivity is WordListeningPracticeActivityModel ? cf.choiceContent : null, controller: controller, diff --git a/lib/pangea/toolbar/message_practice/practice_record_controller.dart b/lib/pangea/toolbar/message_practice/practice_record_controller.dart new file mode 100644 index 000000000..78716b89c --- /dev/null +++ b/lib/pangea/toolbar/message_practice/practice_record_controller.dart @@ -0,0 +1,108 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.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_record.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; + +class PracticeRecordController { + static PracticeRecord recordByActivity(PracticeActivityModel activity) => + PracticeRecordRepo.get(activity.practiceTarget); + + static bool isCompleteByTarget(PracticeTarget target) { + final record = PracticeRecordRepo.get(target); + if (target.activityType == ActivityTypeEnum.morphId) { + return record.completeResponses > 0; + } + + return target.tokens.every( + (t) => record.responses + .any((res) => res.cId == t.vocabConstructID && res.isCorrect), + ); + } + + static bool isCompleteByActivity(PracticeActivityModel activity) { + final activityRecord = recordByActivity(activity); + if (activity.activityType == ActivityTypeEnum.morphId) { + return activityRecord.completeResponses > 0; + } + + return activity.tokens.every( + (t) => activityRecord.responses + .any((res) => res.cId == t.vocabConstructID && res.isCorrect), + ); + } + + static bool isCompleteByToken( + PracticeTarget target, + PangeaToken token, [ + MorphFeaturesEnum? morph, + ]) { + final ConstructIdentifier? cId = + morph == null ? token.vocabConstructID : token.morphIdByFeature(morph); + + if (cId == null) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "isCompleteByToken: cId is null for token ${token.text.content}", + data: { + "t": token.toJson(), + "morph": morph?.name, + }, + ); + return false; + } + + final record = PracticeRecordRepo.get(target); + if (target.activityType == ActivityTypeEnum.morphId) { + return record.responses.any( + (res) => res.cId == token.morphIdByFeature(morph!) && res.isCorrect, + ); + } + + return record.responses.any( + (res) => res.cId == token.vocabConstructID && res.isCorrect, + ); + } + + static bool hasAnyCorrectChoices(PracticeTarget target) { + final record = PracticeRecordRepo.get(target); + return record.responses.any((response) => response.isCorrect); + } + + static bool onSelectChoice( + String choice, + PangeaToken token, + PracticeActivityModel activity, + ) { + final record = recordByActivity(activity); + if (isCompleteByActivity(activity) || + record.alreadyHasMatchResponse( + token.vocabConstructID, + choice, + )) { + return false; + } + + final isCorrect = switch (activity) { + MatchPracticeActivityModel() => activity.isCorrect(token, choice), + MultipleChoicePracticeActivityModel() => activity.isCorrect(choice), + }; + + record.addResponse( + cId: token.vocabConstructID, + target: activity.practiceTarget, + text: choice, + score: isCorrect ? 1 : 0, + ); + + return isCorrect; + } +} diff --git a/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart b/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart index 6cbcb249c..5eaf37f51 100644 --- a/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart +++ b/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart @@ -54,7 +54,7 @@ class ReadingAssistanceInputBarState extends State { children: [ ...MessagePracticeMode.practiceModes.map( (m) { - final complete = widget.controller.isPracticeActivityDone( + final complete = widget.controller.isPracticeSessionDone( m.associatedActivityType!, ); return ToolbarButton( @@ -125,7 +125,7 @@ class _ReadingAssistanceBarContent extends StatelessWidget { } final activityType = mode.associatedActivityType; final activityCompleted = - activityType != null && controller.isPracticeActivityDone(activityType); + activityType != null && controller.isPracticeSessionDone(activityType); switch (mode) { case MessagePracticeMode.noneSelected: diff --git a/lib/pangea/toolbar/message_practice/token_practice_button.dart b/lib/pangea/toolbar/message_practice/token_practice_button.dart index 5ec21afa3..46c1ceff1 100644 --- a/lib/pangea/toolbar/message_practice/token_practice_button.dart +++ b/lib/pangea/toolbar/message_practice/token_practice_button.dart @@ -14,11 +14,13 @@ import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/dotted_border_painter.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.dart'; +import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; const double tokenButtonHeight = 40.0; @@ -48,11 +50,8 @@ class TokenPracticeButton extends StatelessWidget { PracticeTarget? get _activity => controller.practiceTargetForToken(token); bool get isActivityCompleteOrNullForToken { - return _activity?.isCompleteByToken( - token, - _activity!.morphFeature, - ) == - true; + if (_activity == null) return true; + return PracticeRecordController.isCompleteByToken(_activity!, token); } bool get _isEmpty => controller.isPracticeButtonEmpty(token); @@ -94,7 +93,8 @@ class TokenPracticeButton extends StatelessWidget { ), ), shimmer: controller.selectedMorph == null && - _activity?.hasAnyCorrectChoices == false, + _activity != null && + !PracticeRecordController.hasAnyCorrectChoices(_activity!), ); } else { child = _StandardMatchButton( @@ -257,8 +257,9 @@ class _NoActivityContentButton extends StatelessWidget { @override Widget build(BuildContext context) { - if (practiceMode == MessagePracticeMode.wordEmoji) { - final displayEmoji = target?.record.responses + if (practiceMode == MessagePracticeMode.wordEmoji && target != null) { + final record = PracticeRecordRepo.get(target!); + final displayEmoji = record.responses .firstWhereOrNull( (res) => res.cId == token.vocabConstructID && res.isCorrect, ) From f5a75b9f8bd3a77b4c4a4866c4dfa72e7cf485e4 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 13:13:56 -0500 Subject: [PATCH 5/7] cleanup --- .../message_practice/practice_controller.dart | 56 +++++-------- .../message_practice/practice_match_card.dart | 2 - .../practice_record_controller.dart | 78 ++++++++++++++----- .../token_practice_button.dart | 14 +--- 4 files changed, 81 insertions(+), 69 deletions(-) diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 1f572ada2..54ffcbbdf 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -40,29 +40,18 @@ class PracticeController with ChangeNotifier { bool? wasCorrectMatch(PracticeChoice choice) { if (_activity == null) return false; - final record = PracticeRecordController.recordByActivity(_activity!); - for (final response in record.responses) { - if (response.text == choice.choiceContent && response.isCorrect) { - return true; - } - } - for (final response in record.responses) { - if (response.text == choice.choiceContent) { - return false; - } - } - return null; + return PracticeRecordController.wasCorrectMatch( + _activity!.practiceTarget, + choice, + ); } bool? wasCorrectChoice(String choice) { if (_activity == null) return false; - final record = PracticeRecordController.recordByActivity(_activity!); - for (final response in record.responses) { - if (response.text == choice) { - return response.isCorrect; - } - } - return null; + return PracticeRecordController.wasCorrectChoice( + _activity!.practiceTarget, + choice, + ); } bool get isTotallyDone => @@ -107,28 +96,19 @@ class PracticeController with ChangeNotifier { bool get showChoiceShimmer { if (_activity == null) return false; - final record = PracticeRecordController.recordByActivity(_activity!); - if (_activity is MorphMatchPracticeActivityModel) { - return selectedMorph != null && record.responses.isEmpty; + return selectedMorph != null && + !PracticeRecordController.hasResponse( + _activity!.practiceTarget, + ); } - return selectedChoice == null && !record.responses.any((r) => r.isCorrect); + return selectedChoice == null && + !PracticeRecordController.hasAnyCorrectChoices( + _activity!.practiceTarget, + ); } - // PracticeTarget? get _target => _activity != null - // ? PracticeTarget( - // activityType: _activity!.activityType, - // tokens: _activity!.tokens, - // morphFeature: _activity is MorphPracticeActivityModel - // ? (_activity as MorphPracticeActivityModel).morphFeature - // : null, - // ) - // : null; - - // PracticeRecord? get _record => - // _target != null ? PracticeRecordRepo.get(_target!) : null; - Future _fetchPracticeSelection() async { if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return; practiceSelection = await PracticeSelectionRepo.get( @@ -193,7 +173,6 @@ class PracticeController with ChangeNotifier { void onMatch(PangeaToken token, PracticeChoice choice) { if (_activity == null) return; - final record = PracticeRecordController.recordByActivity(_activity!); final isCorrect = PracticeRecordController.onSelectChoice( choice.choiceContent, token, @@ -209,7 +188,8 @@ class PracticeController with ChangeNotifier { // we don't take off points for incorrect emoji matches if (_activity is! EmojiPracticeActivityModel || isCorrect) { final constructUseType = - record.responses.last.useType(_activity!.activityType); + PracticeRecordController.lastResponse(_activity!.practiceTarget)! + .useType(_activity!.activityType); final constructs = [ OneConstructUse( diff --git a/lib/pangea/toolbar/message_practice/practice_match_card.dart b/lib/pangea/toolbar/message_practice/practice_match_card.dart index 69b8f4a96..60506e821 100644 --- a/lib/pangea/toolbar/message_practice/practice_match_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_match_card.dart @@ -21,8 +21,6 @@ class MatchActivityCard extends StatelessWidget { required this.controller, }); - // ActivityTypeEnum get activityType => currentActivity.activityType; - Widget choiceDisplayContent( BuildContext context, String choice, diff --git a/lib/pangea/toolbar/message_practice/practice_record_controller.dart b/lib/pangea/toolbar/message_practice/practice_record_controller.dart index 78716b89c..a32c01b27 100644 --- a/lib/pangea/toolbar/message_practice/practice_record_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_record_controller.dart @@ -2,22 +2,74 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; +import 'package:collection/collection.dart'; + import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.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_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; class PracticeRecordController { - static PracticeRecord recordByActivity(PracticeActivityModel activity) => - PracticeRecordRepo.get(activity.practiceTarget); + static PracticeRecord _recordByTarget(PracticeTarget target) => + PracticeRecordRepo.get(target); + + static bool hasResponse(PracticeTarget target) => + _recordByTarget(target).responses.isNotEmpty; + + static ActivityRecordResponse? lastResponse(PracticeTarget target) { + final record = _recordByTarget(target); + return record.responses.lastOrNull; + } + + static ActivityRecordResponse? correctResponse( + PracticeTarget target, + PangeaToken token, + ) { + final record = _recordByTarget(target); + return record.responses.firstWhereOrNull( + (res) => res.cId == token.vocabConstructID && res.isCorrect, + ); + } + + static bool? wasCorrectMatch( + PracticeTarget target, + PracticeChoice choice, + ) { + final record = _recordByTarget(target); + for (final response in record.responses) { + if (response.text == choice.choiceContent && response.isCorrect) { + return true; + } + } + for (final response in record.responses) { + if (response.text == choice.choiceContent) { + return false; + } + } + return null; + } + + static bool? wasCorrectChoice( + PracticeTarget target, + String choice, + ) { + final record = _recordByTarget(target); + for (final response in record.responses) { + if (response.text == choice) { + return response.isCorrect; + } + } + return null; + } static bool isCompleteByTarget(PracticeTarget target) { - final record = PracticeRecordRepo.get(target); + final record = _recordByTarget(target); if (target.activityType == ActivityTypeEnum.morphId) { return record.completeResponses > 0; } @@ -28,18 +80,6 @@ class PracticeRecordController { ); } - static bool isCompleteByActivity(PracticeActivityModel activity) { - final activityRecord = recordByActivity(activity); - if (activity.activityType == ActivityTypeEnum.morphId) { - return activityRecord.completeResponses > 0; - } - - return activity.tokens.every( - (t) => activityRecord.responses - .any((res) => res.cId == t.vocabConstructID && res.isCorrect), - ); - } - static bool isCompleteByToken( PracticeTarget target, PangeaToken token, [ @@ -60,7 +100,7 @@ class PracticeRecordController { return false; } - final record = PracticeRecordRepo.get(target); + final record = _recordByTarget(target); if (target.activityType == ActivityTypeEnum.morphId) { return record.responses.any( (res) => res.cId == token.morphIdByFeature(morph!) && res.isCorrect, @@ -73,7 +113,7 @@ class PracticeRecordController { } static bool hasAnyCorrectChoices(PracticeTarget target) { - final record = PracticeRecordRepo.get(target); + final record = _recordByTarget(target); return record.responses.any((response) => response.isCorrect); } @@ -82,8 +122,8 @@ class PracticeRecordController { PangeaToken token, PracticeActivityModel activity, ) { - final record = recordByActivity(activity); - if (isCompleteByActivity(activity) || + final record = _recordByTarget(activity.practiceTarget); + if (isCompleteByTarget(activity.practiceTarget) || record.alreadyHasMatchResponse( token.vocabConstructID, choice, diff --git a/lib/pangea/toolbar/message_practice/token_practice_button.dart b/lib/pangea/toolbar/message_practice/token_practice_button.dart index 46c1ceff1..a585e1290 100644 --- a/lib/pangea/toolbar/message_practice/token_practice_button.dart +++ b/lib/pangea/toolbar/message_practice/token_practice_button.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shimmer/shimmer.dart'; @@ -14,7 +13,6 @@ import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/dotted_border_painter.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/message_practice_mode_enum.dart'; @@ -258,14 +256,10 @@ class _NoActivityContentButton extends StatelessWidget { @override Widget build(BuildContext context) { if (practiceMode == MessagePracticeMode.wordEmoji && target != null) { - final record = PracticeRecordRepo.get(target!); - final displayEmoji = record.responses - .firstWhereOrNull( - (res) => res.cId == token.vocabConstructID && res.isCorrect, - ) - ?.text ?? - token.vocabConstructID.userSetEmoji ?? - ''; + final displayEmoji = + PracticeRecordController.correctResponse(target!, token)?.text ?? + token.vocabConstructID.userSetEmoji ?? + ''; return Text( displayEmoji, style: emojiStyle, From 38ff0022fa31771c49d6e53343f732f9f0cad4f1 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 13:39:30 -0500 Subject: [PATCH 6/7] normalize target construct selection for activity targets --- .../practice_activities/practice_target.dart | 9 +++ .../message_practice/practice_controller.dart | 5 -- .../practice_record_controller.dart | 63 +++++-------------- 3 files changed, 26 insertions(+), 51 deletions(-) diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 5e5da4adc..d6a0a1540 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; @@ -95,4 +96,12 @@ class PracticeTarget { return tokens.first.vocabConstructID.lemma; } } + + ConstructIdentifier targetTokenConstructID(PangeaToken token) { + final defaultID = token.vocabConstructID; + final ConstructIdentifier? cId = morphFeature == null + ? defaultID + : token.morphIdByFeature(morphFeature!); + return cId ?? defaultID; + } } diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 54ffcbbdf..5230499c3 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -82,15 +82,10 @@ class PracticeController with ChangeNotifier { return target == null; } - final morph = _activity is MorphPracticeActivityModel - ? (_activity as MorphPracticeActivityModel).morphFeature - : null; - return target == null || PracticeRecordController.isCompleteByToken( target, token, - morph, ); } diff --git a/lib/pangea/toolbar/message_practice/practice_record_controller.dart b/lib/pangea/toolbar/message_practice/practice_record_controller.dart index a32c01b27..9bb3f396e 100644 --- a/lib/pangea/toolbar/message_practice/practice_record_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_record_controller.dart @@ -1,13 +1,6 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.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_choice.dart'; @@ -33,7 +26,7 @@ class PracticeRecordController { ) { final record = _recordByTarget(target); return record.responses.firstWhereOrNull( - (res) => res.cId == token.vocabConstructID && res.isCorrect, + (res) => res.cId == target.targetTokenConstructID(token) && res.isCorrect, ); } @@ -75,41 +68,20 @@ class PracticeRecordController { } return target.tokens.every( - (t) => record.responses - .any((res) => res.cId == t.vocabConstructID && res.isCorrect), + (t) => record.responses.any( + (res) => res.cId == target.targetTokenConstructID(t) && res.isCorrect, + ), ); } static bool isCompleteByToken( PracticeTarget target, - PangeaToken token, [ - MorphFeaturesEnum? morph, - ]) { - final ConstructIdentifier? cId = - morph == null ? token.vocabConstructID : token.morphIdByFeature(morph); - - if (cId == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "isCompleteByToken: cId is null for token ${token.text.content}", - data: { - "t": token.toJson(), - "morph": morph?.name, - }, - ); - return false; - } - - final record = _recordByTarget(target); - if (target.activityType == ActivityTypeEnum.morphId) { - return record.responses.any( - (res) => res.cId == token.morphIdByFeature(morph!) && res.isCorrect, - ); - } - - return record.responses.any( - (res) => res.cId == token.vocabConstructID && res.isCorrect, - ); + PangeaToken token, + ) { + final cId = target.targetTokenConstructID(token); + return _recordByTarget(target).responses.any( + (res) => res.cId == cId && res.isCorrect, + ); } static bool hasAnyCorrectChoices(PracticeTarget target) { @@ -122,12 +94,11 @@ class PracticeRecordController { PangeaToken token, PracticeActivityModel activity, ) { - final record = _recordByTarget(activity.practiceTarget); - if (isCompleteByTarget(activity.practiceTarget) || - record.alreadyHasMatchResponse( - token.vocabConstructID, - choice, - )) { + final target = activity.practiceTarget; + final record = _recordByTarget(target); + final cId = target.targetTokenConstructID(token); + if (isCompleteByTarget(target) || + record.alreadyHasMatchResponse(cId, choice)) { return false; } @@ -137,8 +108,8 @@ class PracticeRecordController { }; record.addResponse( - cId: token.vocabConstructID, - target: activity.practiceTarget, + cId: cId, + target: target, text: choice, score: isCorrect ? 1 : 0, ); From a396664679c5b4f75f614d2d4a608d50de46d6c7 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 16:06:06 -0500 Subject: [PATCH 7/7] fix model name --- .../analytics_practice_session_model.dart | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index 2b35e45e9..b438cce39 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -12,15 +12,15 @@ class AnalyticsPracticeSessionModel { final String userL1; final String userL2; - VocabPracticeSessionState state; + AnalyticsPracticeSessionState state; AnalyticsPracticeSessionModel({ required this.startedAt, required this.practiceTargets, required this.userL1, required this.userL2, - VocabPracticeSessionState? state, - }) : state = state ?? const VocabPracticeSessionState(); + AnalyticsPracticeSessionState? state, + }) : state = state ?? const AnalyticsPracticeSessionState(); int get _availableActivities => min( AnalyticsPracticeConstants.practiceGroupSize, @@ -64,7 +64,7 @@ class AnalyticsPracticeSessionModel { .toList(), userL1: json['userL1'] as String, userL2: json['userL2'] as String, - state: VocabPracticeSessionState.fromJson( + state: AnalyticsPracticeSessionState.fromJson( json, ), ); @@ -81,13 +81,13 @@ class AnalyticsPracticeSessionModel { } } -class VocabPracticeSessionState { +class AnalyticsPracticeSessionState { final List completedUses; final int currentIndex; final bool finished; final int elapsedSeconds; - const VocabPracticeSessionState({ + const AnalyticsPracticeSessionState({ this.completedUses = const [], this.currentIndex = 0, this.finished = false, @@ -139,13 +139,13 @@ class VocabPracticeSessionState { xp: ConstructUseTypeEnum.bonus.pointValue, ); - VocabPracticeSessionState copyWith({ + AnalyticsPracticeSessionState copyWith({ List? completedUses, int? currentIndex, bool? finished, int? elapsedSeconds, }) { - return VocabPracticeSessionState( + return AnalyticsPracticeSessionState( completedUses: completedUses ?? this.completedUses, currentIndex: currentIndex ?? this.currentIndex, finished: finished ?? this.finished, @@ -162,8 +162,8 @@ class VocabPracticeSessionState { }; } - factory VocabPracticeSessionState.fromJson(Map json) { - return VocabPracticeSessionState( + factory AnalyticsPracticeSessionState.fromJson(Map json) { + return AnalyticsPracticeSessionState( completedUses: (json['completedUses'] as List?) ?.map((e) => OneConstructUse.fromJson(e)) .whereType()