From f7539c184f8888808cf8c5e55fbfbe8a82f28fca Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:55:54 -0500 Subject: [PATCH] 5721 practice example message improvements (#5748) * organized analytics practice session repo * refactor target generation for grammar error activities * improve grammar error target generation * more improvements to target generation --- lib/pages/chat/events/html_message.dart | 11 +- .../construct_practice_extension.dart | 14 + .../analytics_misc/example_message_util.dart | 20 +- .../analytics_practice_constants.dart | 1 + .../analytics_practice_page.dart | 7 +- .../analytics_practice_session_model.dart | 14 +- .../analytics_practice_session_repo.dart | 492 ++---------------- .../grammar_error_practice_generator.dart | 8 + .../grammar_error_target_generator.dart | 167 ++++++ .../grammar_match_target_generator.dart | 98 ++++ .../vocab_audio_target_generator.dart | 59 +++ .../vocab_meaning_target_generator.dart | 45 ++ .../choreographer/igc/span_data_model.dart | 4 +- .../practice_activity_model.dart | 104 ++-- .../layout/message_selection_positioner.dart | 16 +- .../message_practice/practice_controller.dart | 185 ++++--- .../message_practice/practice_match_card.dart | 5 +- .../practice_record_controller.dart | 6 +- .../reading_assistance_input_bar.dart | 24 +- .../token_practice_button.dart | 2 +- 20 files changed, 629 insertions(+), 653 deletions(-) create mode 100644 lib/pangea/analytics_misc/construct_practice_extension.dart create mode 100644 lib/pangea/analytics_practice/grammar_error_target_generator.dart create mode 100644 lib/pangea/analytics_practice/grammar_match_target_generator.dart create mode 100644 lib/pangea/analytics_practice/vocab_audio_target_generator.dart create mode 100644 lib/pangea/analytics_practice/vocab_meaning_target_generator.dart diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 0f54e6285..739bef2ea 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -438,6 +438,8 @@ class HtmlMessage extends StatelessWidget { pangeaMessageEvent != null && !pangeaMessageEvent!.ownMessage ? TokensUtil.getNewTokensByEvent(pangeaMessageEvent!) : []; + + final practiceMode = overlayController?.practiceController.practiceMode; // Pangea# switch (node.localName) { @@ -559,10 +561,7 @@ class HtmlMessage extends StatelessWidget { curve: Curves.easeOut, child: SizedBox( height: - overlayController! - .practiceController - .practiceMode != - MessagePracticeMode.noneSelected + practiceMode != MessagePracticeMode.noneSelected ? 4.0 : 0.0, width: tokenWidth, @@ -1003,9 +1002,7 @@ class HtmlMessage extends StatelessWidget { ), curve: Curves.easeOut, child: SizedBox( - height: - overlayController!.practiceController.practiceMode != - MessagePracticeMode.noneSelected + height: practiceMode != MessagePracticeMode.noneSelected ? 4.0 : 0.0, width: 0, diff --git a/lib/pangea/analytics_misc/construct_practice_extension.dart b/lib/pangea/analytics_misc/construct_practice_extension.dart new file mode 100644 index 000000000..ebbf91b71 --- /dev/null +++ b/lib/pangea/analytics_misc/construct_practice_extension.dart @@ -0,0 +1,14 @@ +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; + +extension ConstructPracticeExtension on List { + List practiceSort(ActivityTypeEnum type) { + final sorted = List.from(this); + sorted.sort((a, b) { + final scoreA = a.practiceScore(activityType: type); + final scoreB = b.practiceScore(activityType: type); + return scoreB.compareTo(scoreA); + }); + return sorted; + } +} diff --git a/lib/pangea/analytics_misc/example_message_util.dart b/lib/pangea/analytics_misc/example_message_util.dart index 498c6129a..d8ba12dfd 100644 --- a/lib/pangea/analytics_misc/example_message_util.dart +++ b/lib/pangea/analytics_misc/example_message_util.dart @@ -4,9 +4,11 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; /// Internal result class that holds all computed data from building an example message. class _ExampleMessageResult { @@ -37,14 +39,12 @@ class _ExampleMessageResult { class ExampleMessageUtil { static Future?> getExampleMessage( - ConstructUses construct, - Client client, { + ConstructUses construct, { String? form, bool noBold = false, }) async { final result = await _getExampleMessageResult( construct, - client, form: form, noBold: noBold, ); @@ -52,14 +52,12 @@ class ExampleMessageUtil { } static Future getAudioExampleMessage( - ConstructUses construct, - Client client, { + ConstructUses construct, { String? form, bool noBold = false, }) async { final result = await _getExampleMessageResult( construct, - client, form: form, noBold: noBold, ); @@ -87,14 +85,16 @@ class ExampleMessageUtil { } static Future<_ExampleMessageResult?> _getExampleMessageResult( - ConstructUses construct, - Client client, { + ConstructUses construct, { String? form, bool noBold = false, }) async { - for (final use in construct.cappedUses) { - if (form != null && use.form != form) continue; + final uses = List.from(construct.cappedUses); + uses.shuffle(); // Shuffle to get a random example message each time + for (final use in uses) { + if (form != null && use.form != form) continue; + final client = MatrixState.pangeaController.matrixState.client; final event = await client.getEventByConstructUse(use); if (event == null) continue; diff --git a/lib/pangea/analytics_practice/analytics_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart index a8e06083c..a6e50ee56 100644 --- a/lib/pangea/analytics_practice/analytics_practice_constants.dart +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -2,4 +2,5 @@ class AnalyticsPracticeConstants { static const int timeForBonus = 60; static const int practiceGroupSize = 10; static const int errorBufferSize = 5; + static int get targetsToGenerate => practiceGroupSize + errorBufferSize; } diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index ec492a762..66f4f9966 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -636,11 +636,11 @@ class AnalyticsPracticeState extends State activity, ); - final use = activity.constructUse(choiceContent); - _sessionLoader.value!.submitAnswer(use); + final uses = activity.constructUses(choiceContent); + _sessionLoader.value!.submitAnswer(uses); await _analyticsService.updateService.addAnalytics( choiceTargetId(choiceContent), - [use], + uses, _l2!.langCodeShort, ); @@ -696,7 +696,6 @@ class AnalyticsPracticeState extends State return ExampleMessageUtil.getExampleMessage( await _analyticsService.getConstructUse(construct, _l2!.langCodeShort), - Matrix.of(context).client, ); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index c7c58cfc3..279bdf402 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -1,4 +1,4 @@ -import 'package:flutter/painting.dart'; +import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; @@ -128,11 +128,9 @@ class AnalyticsPracticeSessionModel { }) : state = state ?? const AnalyticsPracticeSessionState(); // Maximum activities to attempt (including skips) - int get _maxAttempts => - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize) - .clamp(0, practiceTargets.length) - .toInt(); + int get _maxAttempts => AnalyticsPracticeConstants.targetsToGenerate + .clamp(0, practiceTargets.length) + .toInt(); int get _completionGoal => AnalyticsPracticeConstants.practiceGroupSize.clamp( 0, @@ -186,8 +184,8 @@ class AnalyticsPracticeSessionModel { void incrementSkippedActivities() => state = state.copyWith(skippedActivities: state.skippedActivities + 1); - void submitAnswer(OneConstructUse use) => - state = state.copyWith(completedUses: [...state.completedUses, use]); + void submitAnswer(List uses) => + state = state.copyWith(completedUses: [...state.completedUses, ...uses]); factory AnalyticsPracticeSessionModel.fromJson(Map json) { return AnalyticsPracticeSessionModel( diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index c0c6f3b1d..6df910d67 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -1,26 +1,13 @@ import 'dart:math'; -import 'package:flutter/material.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/example_message_util.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/analytics_practice/grammar_error_target_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/grammar_match_target_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_audio_target_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_target_generator.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; -import 'package:fluffychat/pangea/languages/language_constants.dart'; -import 'package:fluffychat/pangea/lemmas/lemma.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart'; -import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.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_target.dart'; import 'package:fluffychat/widgets/matrix.dart'; class InsufficientDataException implements Exception {} @@ -36,73 +23,48 @@ class AnalyticsPracticeSessionRepo { } final List targets = []; + final analytics = + MatrixState.pangeaController.matrixState.analyticsDataService; + + final vocabConstructs = await analytics + .getAggregatedConstructs(ConstructTypeEnum.vocab, language) + .then((map) => map.values.toList()); if (type == ConstructTypeEnum.vocab) { - const totalNeeded = - AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize; + final totalNeeded = AnalyticsPracticeConstants.targetsToGenerate; final halfNeeded = (totalNeeded / 2).ceil(); // Fetch audio constructs (with example messages) - final audioMap = await _fetchAudio(language); - final audioCount = min(audioMap.length, halfNeeded); + final audioTargets = await VocabAudioTargetGenerator.get(vocabConstructs); + final audioCount = min(audioTargets.length, halfNeeded); // Fetch vocab constructs to fill the rest final vocabNeeded = totalNeeded - audioCount; - final vocabConstructs = await _fetchVocab(language); - final vocabCount = min(vocabConstructs.length, vocabNeeded); + final vocabTargets = await VocabMeaningTargetGenerator.get( + vocabConstructs, + ); + final vocabCount = min(vocabTargets.length, vocabNeeded); - for (final entry in audioMap.entries.take(audioCount)) { - targets.add( - AnalyticsActivityTarget( - target: PracticeTarget( - tokens: [entry.key.asToken], - activityType: ActivityTypeEnum.lemmaAudio, - ), - audioExampleMessage: entry.value, - ), - ); - } - for (var i = 0; i < vocabCount; i++) { - targets.add( - AnalyticsActivityTarget( - target: PracticeTarget( - tokens: [vocabConstructs[i].asToken], - activityType: ActivityTypeEnum.lemmaMeaning, - ), - ), - ); - } - targets.shuffle(); + final audioTargetsToAdd = audioTargets.take(audioCount); + final meaningTargetsToAdd = vocabTargets.take(vocabCount); + targets.addAll(audioTargetsToAdd); + targets.addAll(meaningTargetsToAdd); } else { - final errorTargets = await _fetchErrors(language); + final errorTargets = await GrammarErrorTargetGenerator.get( + vocabConstructs, + ); targets.addAll(errorTargets); - if (targets.length < - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize)) { - final morphs = await _fetchMorphs(language); + + if (targets.length < AnalyticsPracticeConstants.targetsToGenerate) { + final morphConstructs = await analytics + .getAggregatedConstructs(ConstructTypeEnum.morph, language) + .then((map) => map.values.toList()); + final morphs = await GrammarMatchTargetGenerator.get(morphConstructs); final remainingCount = - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize) - - targets.length; + AnalyticsPracticeConstants.targetsToGenerate - targets.length; + final morphEntries = morphs.take(remainingCount); - - for (final entry in morphEntries) { - targets.add( - AnalyticsActivityTarget( - target: PracticeTarget( - tokens: [entry.token], - activityType: ActivityTypeEnum.grammarCategory, - morphFeature: entry.feature, - ), - exampleMessage: ExampleMessageInfo( - exampleMessage: entry.exampleMessage, - ), - ), - ); - } - - targets.shuffle(); + targets.addAll(morphEntries); } } @@ -110,6 +72,7 @@ class AnalyticsPracticeSessionRepo { throw InsufficientDataException(); } + targets.shuffle(); final session = AnalyticsPracticeSessionModel( userL1: MatrixState.pangeaController.userController.userL1!.langCode, userL2: MatrixState.pangeaController.userController.userL2!.langCode, @@ -118,391 +81,4 @@ class AnalyticsPracticeSessionRepo { ); return session; } - - static Future> _fetchVocab(String language) async { - final constructs = await MatrixState - .pangeaController - .matrixState - .analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.vocab, language) - .then((map) => map.values.toList()); - - // Score and sort by priority (highest first). Uses shared scorer for - // consistent prioritization with message practice. - constructs.sort((a, b) { - final scoreA = a.practiceScore( - activityType: ActivityTypeEnum.lemmaMeaning, - ); - final scoreB = b.practiceScore( - activityType: ActivityTypeEnum.lemmaMeaning, - ); - return scoreB.compareTo(scoreA); - }); - - final Set seemLemmas = {}; - final targets = []; - for (final construct in constructs) { - if (seemLemmas.contains(construct.lemma)) continue; - seemLemmas.add(construct.lemma); - targets.add(construct.id); - if (targets.length >= - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize)) { - break; - } - } - return targets; - } - - static Future> _fetchAudio( - String language, - ) async { - final constructs = await MatrixState - .pangeaController - .matrixState - .analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.vocab, language) - .then((map) => map.values.toList()); - - // Score and sort by priority (highest first). Uses shared scorer for - // consistent prioritization with message practice. - constructs.sort((a, b) { - final scoreA = a.practiceScore(activityType: ActivityTypeEnum.lemmaAudio); - final scoreB = b.practiceScore(activityType: ActivityTypeEnum.lemmaAudio); - return scoreB.compareTo(scoreA); - }); - - final Set seenLemmas = {}; - final Set seenEventIds = {}; - final targets = {}; - - for (final construct in constructs) { - if (targets.length >= - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize)) { - break; - } - - if (seenLemmas.contains(construct.lemma)) continue; - - // Try to get an audio example message with token data for this lemma - final audioExampleMessage = - await ExampleMessageUtil.getAudioExampleMessage( - await MatrixState.pangeaController.matrixState.analyticsDataService - .getConstructUse(construct.id, language), - MatrixState.pangeaController.matrixState.client, - noBold: true, - ); - - // Only add to targets if we found an example message AND its eventId hasn't been used - if (audioExampleMessage != null) { - final eventId = audioExampleMessage.eventId; - if (eventId != null && seenEventIds.contains(eventId)) { - continue; - } - - seenLemmas.add(construct.lemma); - if (eventId != null) { - seenEventIds.add(eventId); - } - targets[construct.id] = audioExampleMessage; - } - } - return targets; - } - - static Future> _fetchMorphs(String language) async { - final constructs = await MatrixState - .pangeaController - .matrixState - .analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.morph, language) - .then((map) => map.values.toList()); - - final morphInfoRequest = MorphInfoRequest( - userL1: - MatrixState.pangeaController.userController.userL1?.langCode ?? - LanguageKeys.defaultLanguage, - userL2: - MatrixState.pangeaController.userController.userL2?.langCode ?? - LanguageKeys.defaultLanguage, - ); - - final morphInfoResult = await MorphInfoRepo.get( - MatrixState.pangeaController.userController.accessToken, - morphInfoRequest, - ); - - // Build list of features with multiple tags (valid for practice) - final List validFeatures = []; - if (!morphInfoResult.isError) { - final response = morphInfoResult.asValue?.value; - if (response != null) { - for (final feature in response.features) { - if (feature.tags.length > 1) { - validFeatures.add(feature.code); - } - } - } - } - - // Score and sort by priority (highest first). Uses shared scorer for - // consistent prioritization with message practice. - constructs.sort((a, b) { - final scoreA = a.practiceScore( - activityType: ActivityTypeEnum.grammarCategory, - ); - final scoreB = b.practiceScore( - activityType: ActivityTypeEnum.grammarCategory, - ); - return scoreB.compareTo(scoreA); - }); - - final targets = []; - final Set seenForms = {}; - - for (final entry in constructs) { - if (targets.length >= - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize)) { - break; - } - - final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); - - // Only include features that are in the valid list (have multiple tags) - if (feature == MorphFeaturesEnum.Unknown || - (validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) { - continue; - } - - List? exampleMessage; - for (final use in entry.cappedUses) { - if (targets.length >= - (AnalyticsPracticeConstants.practiceGroupSize + - AnalyticsPracticeConstants.errorBufferSize)) { - break; - } - - if (use.lemma.isEmpty) continue; - final form = use.form; - if (seenForms.contains(form) || form == null) { - continue; - } - - exampleMessage = await ExampleMessageUtil.getExampleMessage( - await MatrixState.pangeaController.matrixState.analyticsDataService - .getConstructUse(entry.id, language), - MatrixState.pangeaController.matrixState.client, - form: form, - ); - - if (exampleMessage == 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.add( - MorphPracticeTarget( - feature: feature, - token: token, - exampleMessage: exampleMessage, - ), - ); - break; - } - } - - return targets; - } - - static Future> _fetchErrors( - String language, - ) async { - final allRecentUses = await MatrixState - .pangeaController - .matrixState - .analyticsDataService - .getUses( - language, - count: 300, - filterCapped: false, - types: [ - ConstructUseTypeEnum.ga, - ConstructUseTypeEnum.corGE, - ConstructUseTypeEnum.incGE, - ], - ); - - // Filter for grammar error uses - final grammarErrorUses = allRecentUses - .where((use) => use.useType == ConstructUseTypeEnum.ga) - .toList(); - - // Create list of recently practiced constructs (last 24 hours) - final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); - final recentlyPracticedConstructs = allRecentUses - .where( - (use) => - use.metadata.timeStamp.isAfter(cutoffTime) && - (use.useType == ConstructUseTypeEnum.corGE || - use.useType == ConstructUseTypeEnum.incGE), - ) - .map((use) => use.identifier) - .toSet(); - - final client = MatrixState.pangeaController.matrixState.client; - final Map idsToEvents = {}; - - for (final use in grammarErrorUses) { - final eventID = use.metadata.eventId; - if (eventID == null || idsToEvents.containsKey(eventID)) continue; - - final roomID = use.metadata.roomId; - if (roomID == null) { - idsToEvents[eventID] = null; - continue; - } - - final room = client.getRoomById(roomID); - final event = await room?.getEventById(eventID); - if (event == null || event.redacted) { - idsToEvents[eventID] = null; - continue; - } - - final timeline = await room!.getTimeline(); - idsToEvents[eventID] = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: event.senderId == client.userID, - ); - } - - final l2Code = - MatrixState.pangeaController.userController.userL2!.langCodeShort; - - final events = idsToEvents.values.whereType().toList(); - final eventsWithContent = events.where((e) { - final originalSent = e.originalSent; - final choreo = originalSent?.choreo; - final tokens = originalSent?.tokens; - return originalSent?.langCode.split("-").first == l2Code && - choreo != null && - tokens != null && - tokens.isNotEmpty && - choreo.choreoSteps.any( - (step) => - step.acceptedOrIgnoredMatch?.isGrammarMatch == true && - step.acceptedOrIgnoredMatch?.match.bestChoice != null, - ); - }); - - final targets = []; - for (final event in eventsWithContent) { - final originalSent = event.originalSent!; - final choreo = originalSent.choreo!; - final tokens = originalSent.tokens!; - - for (int i = 0; i < choreo.choreoSteps.length; i++) { - final step = choreo.choreoSteps[i]; - final igcMatch = step.acceptedOrIgnoredMatch; - final stepText = choreo.stepText(stepIndex: i - 1); - if (igcMatch?.isGrammarMatch != true || - igcMatch?.match.bestChoice == null) { - continue; - } - - if (igcMatch!.match.offset == 0 && - igcMatch.match.length >= stepText.trim().characters.length) { - continue; - } - - if (igcMatch.match.isNormalizationError()) { - // Skip normalization errors - continue; - } - - final choices = igcMatch.match.choices!.map((c) => c.value).toList(); - final choiceTokens = tokens - .where( - (token) => - token.lemma.saveVocab && - choices.any((choice) => choice.contains(token.text.content)), - ) - .toList(); - - // Skip if no valid tokens found for this grammar error, or only one answer - if (choiceTokens.length <= 1) { - continue; - } - - final firstToken = choiceTokens.first; - final tokenIdentifier = ConstructIdentifier( - lemma: firstToken.lemma.text, - type: ConstructTypeEnum.vocab, - category: firstToken.pos, - ); - - final hasRecentPractice = recentlyPracticedConstructs.contains( - tokenIdentifier, - ); - - if (hasRecentPractice) continue; - - String? translation; - try { - translation = await event.requestRespresentationByL1(); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'context': 'AnalyticsPracticeSessionRepo._fetchErrors', - 'message': 'Failed to fetch translation for analytics practice', - 'event_id': event.eventId, - }, - ); - } - - if (translation == null) continue; - - targets.add( - AnalyticsActivityTarget( - target: PracticeTarget( - tokens: choiceTokens, - activityType: ActivityTypeEnum.grammarError, - morphFeature: null, - ), - grammarErrorInfo: GrammarErrorRequestInfo( - choreo: choreo, - stepIndex: i, - eventID: event.eventId, - translation: translation, - ), - ), - ); - } - } - - return targets; - } -} - -class MorphPracticeTarget { - final PangeaToken token; - final MorphFeaturesEnum feature; - final List exampleMessage; - - MorphPracticeTarget({ - required this.token, - required this.feature, - required this.exampleMessage, - }); } diff --git a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart index 762f8005a..b88c64a97 100644 --- a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart +++ b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart @@ -33,6 +33,14 @@ class GrammarErrorPracticeGenerator { choices.add(errorSpan); } + if (igcMatch.offset + igcMatch.length > stepText.characters.length) { + // Sometimes choreo records turn out weird when users edit the message + // mid-IGC. If the offsets / lengths don't make sense, skip this target. + throw Exception( + "IGC match offset and length exceed step text length. Step text: '$stepText', match offset: ${igcMatch.offset}, match length: ${igcMatch.length}", + ); + } + choices.shuffle(); return MessageActivityResponse( activity: GrammarErrorPracticeActivityModel( diff --git a/lib/pangea/analytics_practice/grammar_error_target_generator.dart b/lib/pangea/analytics_practice/grammar_error_target_generator.dart new file mode 100644 index 000000000..2d6e01dfa --- /dev/null +++ b/lib/pangea/analytics_practice/grammar_error_target_generator.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.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_target.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class GrammarErrorTargetGenerator { + static ActivityTypeEnum activityType = ActivityTypeEnum.grammarError; + + static Future> get( + List constructs, + ) async { + final client = MatrixState.pangeaController.matrixState.client; + final Map seenEventIDs = {}; + final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); + + final targets = []; + for (final construct in constructs) { + final lastPracticeUse = construct.lastUseByTypes( + activityType.associatedUseTypes, + ); + + if (lastPracticeUse != null && lastPracticeUse.isAfter(cutoffTime)) { + continue; + } + + final errorUses = construct.cappedUses.where( + (u) => u.useType == ConstructUseTypeEnum.ga, + ); + if (errorUses.isEmpty) continue; + + for (final use in errorUses) { + final eventID = use.metadata.eventId; + if (eventID == null) continue; + if (seenEventIDs.containsKey(eventID) && + seenEventIDs[eventID] == null) { + continue; // Already checked this event and it had no valid grammar error match + } + + final event = + seenEventIDs[eventID] ?? await client.getEventByConstructUse(use); + + seenEventIDs[eventID] = event; + } + } + + final events = seenEventIDs.values.whereType(); + for (final event in events) { + final eventTargets = await _getTargetFromEvent(event); + targets.addAll(eventTargets); + if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) { + return targets; + } + } + + return targets; + } + + static Future> _getTargetFromEvent( + PangeaMessageEvent event, + ) async { + final List targets = []; + final l2Code = + MatrixState.pangeaController.userController.userL2!.langCodeShort; + final originalSent = event.originalSent; + if (originalSent?.langCode.split("-").first != l2Code) { + return targets; + } + + final choreo = originalSent?.choreo; + if (choreo == null || + !choreo.choreoSteps.any( + (step) => + step.acceptedOrIgnoredMatch?.isGrammarMatch == true && + step.acceptedOrIgnoredMatch?.match.bestChoice != null, + )) { + return targets; + } + + final tokens = originalSent?.tokens; + if (tokens == null || tokens.isEmpty) { + return targets; + } + + String? translation; + for (int i = 0; i < choreo.choreoSteps.length; i++) { + final step = choreo.choreoSteps[i]; + final igcMatch = step.acceptedOrIgnoredMatch; + if (igcMatch?.isGrammarMatch != true || + igcMatch?.match.bestChoice == null) { + continue; + } + + final stepText = choreo.stepText(stepIndex: i - 1); + final errorSpan = stepText.characters + .skip(igcMatch!.match.offset) + .take(igcMatch.match.length) + .toString(); + + if (igcMatch.match.isNormalizationError(errorSpanOverride: errorSpan)) { + continue; + } + + if (igcMatch.match.offset == 0 && + igcMatch.match.length >= stepText.trim().characters.length) { + continue; + } + + final choices = igcMatch.match.choices!.map((c) => c.value).toList(); + final choiceTokens = tokens + .where( + (token) => + token.lemma.saveVocab && + choices.any((choice) => choice.contains(token.text.content)), + ) + .toList(); + + // Skip if no valid tokens found for this grammar error, or only one answer + if (choiceTokens.isEmpty) { + continue; + } + + try { + translation ??= await event.requestRespresentationByL1(); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'context': 'AnalyticsPracticeSessionRepo._fetchErrors', + 'message': 'Failed to fetch translation for analytics practice', + 'event_id': event.eventId, + }, + ); + } + + if (translation == null) { + continue; + } + + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: choiceTokens, + activityType: activityType, + ), + grammarErrorInfo: GrammarErrorRequestInfo( + choreo: choreo, + stepIndex: i, + eventID: event.eventId, + translation: translation, + ), + ), + ); + } + + return targets; + } +} diff --git a/lib/pangea/analytics_practice/grammar_match_target_generator.dart b/lib/pangea/analytics_practice/grammar_match_target_generator.dart new file mode 100644 index 000000000..4b37528bb --- /dev/null +++ b/lib/pangea/analytics_practice/grammar_match_target_generator.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/example_message_util.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_form.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/morphs/morph_repo.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 GrammarMatchTargetGenerator { + static ActivityTypeEnum activityType = ActivityTypeEnum.grammarCategory; + + static Future> get( + List constructs, + ) async { + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. + final sortedConstructs = constructs.practiceSort(activityType); + + final Set seenForms = {}; + + final morphInfoResult = await MorphsRepo.get( + MatrixState.pangeaController.userController.userL2, + ); + + // Build list of features with multiple tags (valid for practice) + final List validFeatures = morphInfoResult.features + .where((f) => f.tags.length > 1) + .map((f) => f.feature) + .toList(); + + final targets = []; + + for (final construct in sortedConstructs) { + if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) { + break; + } + + final feature = MorphFeaturesEnumExtension.fromString(construct.category); + + // Only include features that are in the valid list (have multiple tags) + if (feature == MorphFeaturesEnum.Unknown || + (validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) { + continue; + } + + List? exampleMessage; + final constructForms = construct.cappedUses + .where((u) => u.form != null) + .map((u) => ConstructForm(form: u.form!, cId: construct.id)) + .toSet(); + + for (final form in constructForms) { + if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) { + break; + } + + if (seenForms.contains(form.form)) continue; + seenForms.add(form.form); + + exampleMessage = await ExampleMessageUtil.getExampleMessage( + construct, + form: form.form, + ); + if (exampleMessage == null) continue; + + final token = PangeaToken( + lemma: Lemma(text: form.form, saveVocab: true, form: form.form), + text: PangeaTokenText.fromString(form.form), + pos: 'other', + morph: {feature: form.cId.lemma}, + ); + + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [token], + activityType: activityType, + morphFeature: feature, + ), + exampleMessage: ExampleMessageInfo(exampleMessage: exampleMessage), + ), + ); + break; + } + } + + return targets; + } +} diff --git a/lib/pangea/analytics_practice/vocab_audio_target_generator.dart b/lib/pangea/analytics_practice/vocab_audio_target_generator.dart new file mode 100644 index 000000000..c518b8287 --- /dev/null +++ b/lib/pangea/analytics_practice/vocab_audio_target_generator.dart @@ -0,0 +1,59 @@ +import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/example_message_util.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/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; + +class VocabAudioTargetGenerator { + static ActivityTypeEnum activityType = ActivityTypeEnum.lemmaAudio; + + static Future> get( + List constructs, + ) async { + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. + final sortedConstructs = constructs.practiceSort(activityType); + + final Set seenLemmas = {}; + final Set seenEventIds = {}; + final targets = []; + + for (final construct in sortedConstructs) { + if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) { + break; + } + + if (seenLemmas.contains(construct.lemma)) continue; + + // Try to get an audio example message with token data for this lemma + final exampleMessage = await ExampleMessageUtil.getAudioExampleMessage( + construct, + noBold: true, + ); + + if (exampleMessage == null) continue; + final eventId = exampleMessage.eventId; + if (eventId != null && seenEventIds.contains(eventId)) { + continue; + } + + seenLemmas.add(construct.lemma); + if (eventId != null) { + seenEventIds.add(eventId); + } + + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [construct.id.asToken], + activityType: activityType, + ), + audioExampleMessage: exampleMessage, + ), + ); + } + return targets; + } +} diff --git a/lib/pangea/analytics_practice/vocab_meaning_target_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_target_generator.dart new file mode 100644 index 000000000..b5de0800d --- /dev/null +++ b/lib/pangea/analytics_practice/vocab_meaning_target_generator.dart @@ -0,0 +1,45 @@ +import 'package:fluffychat/pangea/analytics_misc/construct_practice_extension.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.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/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; + +class VocabMeaningTargetGenerator { + static ActivityTypeEnum activityType = ActivityTypeEnum.lemmaMeaning; + + static Future> get( + List constructs, + ) async { + // Score and sort by priority (highest first). Uses shared scorer for + // consistent prioritization with message practice. + final sortedConstructs = constructs.practiceSort(activityType); + + final Set seenLemmas = {}; + final targets = []; + for (final construct in sortedConstructs) { + if (seenLemmas.contains(construct.lemma)) continue; + seenLemmas.add(construct.lemma); + + if (!construct.cappedUses.any( + (u) => u.metadata.eventId != null && u.metadata.roomId != null, + )) { + // Skip if no uses have eventId + roomId, so example message can be fetched. + continue; + } + + targets.add( + AnalyticsActivityTarget( + target: PracticeTarget( + tokens: [construct.id.asToken], + activityType: activityType, + ), + ), + ); + if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) { + break; + } + } + return targets; + } +} diff --git a/lib/pangea/choreographer/igc/span_data_model.dart b/lib/pangea/choreographer/igc/span_data_model.dart index 8861c6ba5..a00cb3a0a 100644 --- a/lib/pangea/choreographer/igc/span_data_model.dart +++ b/lib/pangea/choreographer/igc/span_data_model.dart @@ -159,7 +159,7 @@ class SpanData { /// 1. The type is explicitly marked as auto-apply (e.g., punct, spell, cap, diacritics), OR /// 2. For backwards compatibility with old data that lacks new types: /// the type is NOT auto-apply AND the normalized strings match. - bool isNormalizationError() { + bool isNormalizationError({String? errorSpanOverride}) { // New data with explicit auto-apply types if (type.isAutoApply) { return true; @@ -175,7 +175,7 @@ class SpanData { return correctChoice != null && l2Code != null && normalizeString(correctChoice, l2Code) == - normalizeString(errorSpan, l2Code); + normalizeString(errorSpanOverride ?? errorSpan, l2Code); } @override diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 76fe845e6..665bac0e1 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -28,6 +28,8 @@ sealed class PracticeActivityModel { : null, ); + bool isCorrect(String choice, PangeaToken token) => false; + ActivityTypeEnum get activityType { switch (this) { case MorphCategoryPracticeActivityModel(): @@ -225,24 +227,32 @@ sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel { required this.multipleChoiceContent, }); - bool isCorrect(String choice) => multipleChoiceContent.isCorrect(choice); + @override + bool isCorrect(String choice, PangeaToken _) => + multipleChoiceContent.isCorrect(choice); - OneConstructUse constructUse(String choiceContent) { + List constructUses(String choiceContent) { final correct = multipleChoiceContent.isCorrect(choiceContent); final useType = correct ? activityType.correctUse : activityType.incorrectUse; - final token = tokens.first; - return OneConstructUse( - useType: useType, - constructType: ConstructTypeEnum.vocab, - metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()), - category: token.pos, - lemma: token.lemma.text, - form: token.lemma.text, - xp: useType.pointValue, - ); + return tokens + .map( + (token) => OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: token.pos, + lemma: token.lemma.text, + form: token.lemma.text, + xp: useType.pointValue, + ), + ) + .toList(); } @override @@ -262,7 +272,8 @@ sealed class MatchPracticeActivityModel extends PracticeActivityModel { required this.matchContent, }); - bool isCorrect(PangeaToken token, String choice) => + @override + bool isCorrect(String choice, PangeaToken token) => matchContent.matchInfo[token.vocabForm]!.contains(choice); @override @@ -288,6 +299,31 @@ sealed class MorphPracticeActivityModel String get storageKey => '${activityType.name}-${tokens.map((e) => e.text.content).join("-")}-${morphFeature.name}'; + @override + List constructUses(String choiceContent) { + final correct = multipleChoiceContent.isCorrect(choiceContent); + final useType = correct + ? activityType.correctUse + : activityType.incorrectUse; + + return tokens + .map( + (token) => OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.morph, + metadata: ConstructUseMetaData( + roomId: null, + timeStamp: DateTime.now(), + ), + category: morphFeature.name, + lemma: token.getMorphTag(morphFeature)!, + form: token.lemma.form, + xp: useType.pointValue, + ), + ) + .toList(); + } + @override Map toJson() { final json = super.toJson(); @@ -306,26 +342,6 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { required this.exampleMessageInfo, }); - @override - OneConstructUse constructUse(String choiceContent) { - final correct = multipleChoiceContent.isCorrect(choiceContent); - final token = tokens.first; - final useType = correct - ? activityType.correctUse - : activityType.incorrectUse; - final tag = token.getMorphTag(morphFeature)!; - - return OneConstructUse( - useType: useType, - constructType: ConstructTypeEnum.morph, - metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()), - category: morphFeature.name, - lemma: tag, - form: token.lemma.form, - xp: useType.pointValue, - ); - } - @override Map toJson() { final json = super.toJson(); @@ -359,7 +375,7 @@ class VocabAudioPracticeActivityModel }); @override - OneConstructUse constructUse(String choiceContent) { + List constructUses(String choiceContent) { final correct = multipleChoiceContent.isCorrect(choiceContent); final useType = correct ? activityType.correctUse @@ -371,15 +387,17 @@ class VocabAudioPracticeActivityModel orElse: () => tokens.first, ); - return OneConstructUse( - useType: useType, - constructType: ConstructTypeEnum.vocab, - metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()), - category: matchingToken.pos, - lemma: matchingToken.lemma.text, - form: matchingToken.lemma.text, - xp: useType.pointValue, - ); + return [ + OneConstructUse( + useType: useType, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()), + category: matchingToken.pos, + lemma: matchingToken.lemma.text, + form: matchingToken.lemma.text, + xp: useType.pointValue, + ), + ]; } @override diff --git a/lib/pangea/toolbar/layout/message_selection_positioner.dart b/lib/pangea/toolbar/layout/message_selection_positioner.dart index c7d3c9c00..67d9d5ee0 100644 --- a/lib/pangea/toolbar/layout/message_selection_positioner.dart +++ b/lib/pangea/toolbar/layout/message_selection_positioner.dart @@ -485,22 +485,14 @@ class MessageSelectionPositionerState extends State final practice = widget.overlayController.practiceController; - final instruction = - practice.practiceMode.instruction; - - final type = - practice.practiceMode.associatedActivityType; + final practiceMode = practice.practiceMode; + final instruction = practiceMode.instruction; final complete = - type != null && - practice.isPracticeSessionDone(type); + practice.isCurrentPracticeSessionDone; if (instruction != null && !complete) { return InstructionsInlineTooltip( - instructionsEnum: widget - .overlayController - .practiceController - .practiceMode - .instruction!, + instructionsEnum: practiceMode.instruction!, padding: const EdgeInsets.all(16.0), animate: false, ); diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index ea266055d..126541be9 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -33,13 +33,55 @@ class PracticeController with ChangeNotifier { PracticeActivityModel? _activity; - MessagePracticeMode practiceMode = MessagePracticeMode.noneSelected; + MessagePracticeMode _practiceMode = MessagePracticeMode.noneSelected; - MorphSelection? selectedMorph; - PracticeChoice? selectedChoice; + MorphSelection? _selectedMorph; + PracticeChoice? _selectedChoice; PracticeSelection? practiceSelection; + MessagePracticeMode get practiceMode => _practiceMode; + MorphSelection? get selectedMorph => _selectedMorph; + PracticeChoice? get selectedChoice => _selectedChoice; + + PracticeTarget? get currentTarget { + final activityType = _practiceMode.associatedActivityType; + if (activityType == null) return null; + if (activityType == ActivityTypeEnum.morphId) { + if (_selectedMorph == null) return null; + return practiceSelection?.getMorphTarget( + _selectedMorph!.token, + _selectedMorph!.morph, + ); + } + return practiceSelection?.getTarget(activityType); + } + + bool get showChoiceShimmer { + if (_activity == null) return false; + if (_activity is MorphMatchPracticeActivityModel) { + return _selectedMorph != null && + !PracticeRecordController.hasResponse(_activity!.practiceTarget); + } + + return _selectedChoice == null && + !PracticeRecordController.hasAnyCorrectChoices( + _activity!.practiceTarget, + ); + } + + bool get isTotallyDone => + isPracticeSessionDone(ActivityTypeEnum.emoji) && + isPracticeSessionDone(ActivityTypeEnum.wordMeaning) && + isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) && + isPracticeSessionDone(ActivityTypeEnum.morphId); + + bool get isCurrentPracticeSessionDone { + final activityType = _practiceMode.associatedActivityType; + if (activityType == null) return false; + return isPracticeSessionDone(activityType); + } + bool? wasCorrectMatch(PracticeChoice choice) { if (_activity == null) return false; return PracticeRecordController.wasCorrectMatch( @@ -56,12 +98,6 @@ class PracticeController with ChangeNotifier { ); } - bool get isTotallyDone => - isPracticeSessionDone(ActivityTypeEnum.emoji) && - isPracticeSessionDone(ActivityTypeEnum.wordMeaning) && - isPracticeSessionDone(ActivityTypeEnum.wordFocusListening) && - isPracticeSessionDone(ActivityTypeEnum.morphId); - bool isPracticeSessionDone(ActivityTypeEnum activityType) => practiceSelection ?.activities(activityType) @@ -70,99 +106,50 @@ class PracticeController with ChangeNotifier { bool isPracticeButtonEmpty(PangeaToken token) { final target = practiceTargetForToken(token); - - if (MessagePracticeMode.wordEmoji == practiceMode) { - if (token.vocabConstructID.userSetEmoji != null) { - return false; - } - // Keep open even when completed to show emoji - return target == null; - } - - if (MessagePracticeMode.wordMorph == practiceMode) { - // Keep open even when completed to show morph icon - return target == null; - } - - return target == null || - PracticeRecordController.isCompleteByToken(target, token); - } - - bool get showChoiceShimmer { - if (_activity == null) return false; - if (_activity is MorphMatchPracticeActivityModel) { - return selectedMorph != null && - !PracticeRecordController.hasResponse(_activity!.practiceTarget); - } - - return selectedChoice == null && - !PracticeRecordController.hasAnyCorrectChoices( - _activity!.practiceTarget, - ); - } - - Future _fetchPracticeSelection() async { - if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return; - practiceSelection = await PracticeSelectionRepo.get( - pangeaMessageEvent.eventId, - pangeaMessageEvent.messageDisplayLangCode, - pangeaMessageEvent.messageDisplayRepresentation!.tokens!, - ); - } - - Future> fetchActivityModel( - PracticeTarget target, - ) async { - final req = MessageActivityRequest( - userL1: MatrixState.pangeaController.userController.userL1!.langCode, - userL2: MatrixState.pangeaController.userController.userL2!.langCode, - activityQualityFeedback: null, - target: target, - ); - - final result = await PracticeRepo.getPracticeActivity( - req, - messageInfo: pangeaMessageEvent.event.content, - ); - if (result.isValue) { - _activity = result.result; - } - - return result; + return switch (_practiceMode) { + // Keep open when completed if emoji assigned + MessagePracticeMode.wordEmoji => + target == null || token.vocabConstructID.userSetEmoji != null, + // Keep open when completed to show morph icon + MessagePracticeMode.wordMorph => target == null, + _ => + target == null || + PracticeRecordController.isCompleteByToken(target, token), + }; } PracticeTarget? practiceTargetForToken(PangeaToken token) { - if (practiceMode.associatedActivityType == null) return null; + if (_practiceMode.associatedActivityType == null) return null; return practiceSelection - ?.activities(practiceMode.associatedActivityType!) + ?.activities(_practiceMode.associatedActivityType!) .firstWhereOrNull((a) => a.tokens.contains(token)); } void updateToolbarMode(MessagePracticeMode mode) { - selectedChoice = null; - practiceMode = mode; - if (practiceMode != MessagePracticeMode.wordMorph) { - selectedMorph = null; + _selectedChoice = null; + _practiceMode = mode; + if (_practiceMode != MessagePracticeMode.wordMorph) { + _selectedMorph = null; } notifyListeners(); } - void onChoiceSelect(PracticeChoice? choice, [bool force = false]) { + void updatePracticeMorph(MorphSelection newMorph) { + _practiceMode = MessagePracticeMode.wordMorph; + _selectedMorph = newMorph; + notifyListeners(); + } + + void onChoiceSelect(PracticeChoice? choice) { if (_activity == null) return; - if (selectedChoice == choice && !force) { - selectedChoice = null; + if (_selectedChoice == choice) { + _selectedChoice = null; } else { - selectedChoice = choice; + _selectedChoice = choice; } notifyListeners(); } - void onSelectMorph(MorphSelection newMorph) { - practiceMode = MessagePracticeMode.wordMorph; - selectedMorph = newMorph; - notifyListeners(); - } - void onMatch(PangeaToken token, PracticeChoice choice) { if (_activity == null) return; final isCorrect = PracticeRecordController.onSelectChoice( @@ -248,4 +235,34 @@ class PracticeController with ChangeNotifier { notifyListeners(); } + + Future _fetchPracticeSelection() async { + if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return; + practiceSelection = await PracticeSelectionRepo.get( + pangeaMessageEvent.eventId, + pangeaMessageEvent.messageDisplayLangCode, + pangeaMessageEvent.messageDisplayRepresentation!.tokens!, + ); + } + + Future> fetchActivityModel( + PracticeTarget target, + ) async { + final req = MessageActivityRequest( + userL1: MatrixState.pangeaController.userController.userL1!.langCode, + userL2: MatrixState.pangeaController.userController.userL2!.langCode, + activityQualityFeedback: null, + target: target, + ); + + final result = await PracticeRepo.getPracticeActivity( + req, + messageInfo: pangeaMessageEvent.event.content, + ); + if (result.isValue) { + _activity = result.result; + } + + return result; + } } diff --git a/lib/pangea/toolbar/message_practice/practice_match_card.dart b/lib/pangea/toolbar/message_practice/practice_match_card.dart index 189b59508..27b8143b4 100644 --- a/lib/pangea/toolbar/message_practice/practice_match_card.dart +++ b/lib/pangea/toolbar/message_practice/practice_match_card.dart @@ -59,6 +59,7 @@ class MatchActivityCard extends StatelessWidget { fontSize = fontSize * 1.5; } + final selectedChoice = controller.selectedChoice; return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.max, @@ -75,13 +76,13 @@ class MatchActivityCard extends StatelessWidget { ) { final bool? wasCorrect = controller.wasCorrectMatch(cf); return ChoiceAnimationWidget( - isSelected: controller.selectedChoice == cf, + isSelected: selectedChoice == cf, isCorrect: wasCorrect, child: PracticeMatchItem( token: currentActivity.tokens.firstWhereOrNull( (t) => t.vocabConstructID == cf.form.cId, ), - isSelected: controller.selectedChoice == cf, + isSelected: selectedChoice == cf, isCorrect: wasCorrect, constructForm: cf, content: choiceDisplayContent( diff --git a/lib/pangea/toolbar/message_practice/practice_record_controller.dart b/lib/pangea/toolbar/message_practice/practice_record_controller.dart index 831a08da1..86d62e76a 100644 --- a/lib/pangea/toolbar/message_practice/practice_record_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_record_controller.dart @@ -93,11 +93,7 @@ class PracticeRecordController { return false; } - final isCorrect = switch (activity) { - MatchPracticeActivityModel() => activity.isCorrect(token, choice), - MultipleChoicePracticeActivityModel() => activity.isCorrect(choice), - }; - + final isCorrect = activity.isCorrect(choice, token); record.addResponse( cId: cId, target: target, 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 3385594e4..95fd447e1 100644 --- a/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart +++ b/lib/pangea/toolbar/message_practice/reading_assistance_input_bar.dart @@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -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/practice_activity_card.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.dart'; @@ -56,14 +55,15 @@ class ReadingAssistanceInputBarState extends State { final complete = widget.controller.isPracticeSessionDone( m.associatedActivityType!, ); + + final practiceMode = widget.controller.practiceMode; return ToolbarButton( mode: m, setMode: () => widget.controller.updateToolbarMode(m), isComplete: complete, - isSelected: widget.controller.practiceMode == m, + isSelected: practiceMode == m, shimmer: - widget.controller.practiceMode == - MessagePracticeMode.noneSelected && + practiceMode == MessagePracticeMode.noneSelected && !complete, ); }), @@ -122,9 +122,9 @@ class _ReadingAssistanceBarContent extends StatelessWidget { if (controller.pangeaMessageEvent.isAudioMessage == true) { return const SizedBox(); } - final activityType = mode.associatedActivityType; - final activityCompleted = - activityType != null && controller.isPracticeSessionDone(activityType); + + final target = controller.currentTarget; + final activityCompleted = controller.isCurrentPracticeSessionDone; switch (mode) { case MessagePracticeMode.noneSelected: @@ -139,7 +139,6 @@ class _ReadingAssistanceBarContent extends StatelessWidget { return const _AllDoneWidget(); } - final target = controller.practiceSelection?.getTarget(activityType!); if (target == null || activityCompleted) { return const Icon( Symbols.fitness_center, @@ -166,15 +165,6 @@ class _ReadingAssistanceBarContent extends StatelessWidget { ); } - PracticeTarget? target; - if (controller.practiceSelection != null && - controller.selectedMorph != null) { - target = controller.practiceSelection!.getMorphTarget( - controller.selectedMorph!.token, - controller.selectedMorph!.morph, - ); - } - if (target == null) { return const Center(child: Icon(Symbols.fitness_center, size: 60.0)); } diff --git a/lib/pangea/toolbar/message_practice/token_practice_button.dart b/lib/pangea/toolbar/message_practice/token_practice_button.dart index 0dd8f470a..3cba7addd 100644 --- a/lib/pangea/toolbar/message_practice/token_practice_button.dart +++ b/lib/pangea/toolbar/message_practice/token_practice_button.dart @@ -84,7 +84,7 @@ class TokenPracticeButton extends StatelessWidget { active: _isSelected, textColor: textColor, width: tokenButtonHeight, - onTap: () => controller.onSelectMorph( + onTap: () => controller.updatePracticeMorph( MorphSelection(token, _activity!.morphFeature!), ), shimmer: