From 326e5c32412774ce8c88954e430dd7ef6aa6705d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 15 Jan 2026 09:59:28 -0500 Subject: [PATCH] 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);