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
This commit is contained in:
ggurdin 2026-02-18 14:55:54 -05:00 committed by GitHub
parent 5c645904d0
commit f7539c184f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 629 additions and 653 deletions

View file

@ -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,

View file

@ -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<ConstructUses> {
List<ConstructUses> practiceSort(ActivityTypeEnum type) {
final sorted = List<ConstructUses>.from(this);
sorted.sort((a, b) {
final scoreA = a.practiceScore(activityType: type);
final scoreB = b.practiceScore(activityType: type);
return scoreB.compareTo(scoreA);
});
return sorted;
}
}

View file

@ -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<List<InlineSpan>?> 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<AudioExampleMessage?> 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<OneConstructUse>.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;

View file

@ -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;
}

View file

@ -636,11 +636,11 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
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<AnalyticsPractice>
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct, _l2!.langCodeShort),
Matrix.of(context).client,
);
}

View file

@ -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<OneConstructUse> uses) =>
state = state.copyWith(completedUses: [...state.completedUses, ...uses]);
factory AnalyticsPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return AnalyticsPracticeSessionModel(

View file

@ -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<AnalyticsActivityTarget> 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<List<ConstructIdentifier>> _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<String> seemLemmas = {};
final targets = <ConstructIdentifier>[];
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<Map<ConstructIdentifier, AudioExampleMessage>> _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<String> seenLemmas = {};
final Set<String> seenEventIds = {};
final targets = <ConstructIdentifier, AudioExampleMessage>{};
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<List<MorphPracticeTarget>> _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<String> 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 = <MorphPracticeTarget>[];
final Set<String> 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<InlineSpan>? 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<List<AnalyticsActivityTarget>> _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<String, PangeaMessageEvent?> 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<PangeaMessageEvent>().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 = <AnalyticsActivityTarget>[];
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<InlineSpan> exampleMessage;
MorphPracticeTarget({
required this.token,
required this.feature,
required this.exampleMessage,
});
}

View file

@ -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(

View file

@ -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<List<AnalyticsActivityTarget>> get(
List<ConstructUses> constructs,
) async {
final client = MatrixState.pangeaController.matrixState.client;
final Map<String, PangeaMessageEvent?> seenEventIDs = {};
final cutoffTime = DateTime.now().subtract(const Duration(hours: 24));
final targets = <AnalyticsActivityTarget>[];
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<PangeaMessageEvent>();
for (final event in events) {
final eventTargets = await _getTargetFromEvent(event);
targets.addAll(eventTargets);
if (targets.length >= AnalyticsPracticeConstants.targetsToGenerate) {
return targets;
}
}
return targets;
}
static Future<List<AnalyticsActivityTarget>> _getTargetFromEvent(
PangeaMessageEvent event,
) async {
final List<AnalyticsActivityTarget> 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;
}
}

View file

@ -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<List<AnalyticsActivityTarget>> get(
List<ConstructUses> 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<String> seenForms = {};
final morphInfoResult = await MorphsRepo.get(
MatrixState.pangeaController.userController.userL2,
);
// Build list of features with multiple tags (valid for practice)
final List<String> validFeatures = morphInfoResult.features
.where((f) => f.tags.length > 1)
.map((f) => f.feature)
.toList();
final targets = <AnalyticsActivityTarget>[];
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<InlineSpan>? 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;
}
}

View file

@ -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<List<AnalyticsActivityTarget>> get(
List<ConstructUses> 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<String> seenLemmas = {};
final Set<String> seenEventIds = {};
final targets = <AnalyticsActivityTarget>[];
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;
}
}

View file

@ -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<List<AnalyticsActivityTarget>> get(
List<ConstructUses> 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<String> seenLemmas = {};
final targets = <AnalyticsActivityTarget>[];
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;
}
}

View file

@ -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

View file

@ -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<OneConstructUse> 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<OneConstructUse> 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<String, dynamic> 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<String, dynamic> toJson() {
final json = super.toJson();
@ -359,7 +375,7 @@ class VocabAudioPracticeActivityModel
});
@override
OneConstructUse constructUse(String choiceContent) {
List<OneConstructUse> 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

View file

@ -485,22 +485,14 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
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,
);

View file

@ -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<void> _fetchPracticeSelection() async {
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return;
practiceSelection = await PracticeSelectionRepo.get(
pangeaMessageEvent.eventId,
pangeaMessageEvent.messageDisplayLangCode,
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
);
}
Future<Result<PracticeActivityModel>> 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<void> _fetchPracticeSelection() async {
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return;
practiceSelection = await PracticeSelectionRepo.get(
pangeaMessageEvent.eventId,
pangeaMessageEvent.messageDisplayLangCode,
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
);
}
Future<Result<PracticeActivityModel>> 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;
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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<ReadingAssistanceInputBar> {
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));
}

View file

@ -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: