fluffychat/lib/pangea/practice_activities/lemma_activity_generator.dart
ggurdin af395d0aeb
4825 vocabulary practice (#4826)
* chore: move logic for lastUsedByActivityType into ConstructIdentifier

* feat: vocab practice

* add vocab activity progress bar

* fix: shuffle audio practice choices

* update UI of vocab practice

Added buttons, increased text size and change position, cards flip over and turn red/green on click and respond to hover input

* add xp sparkle, shimmering choice card placeholder

* spacing changes

fix padding, make choice cards spacing/sizing responsive to screen size, replace shimmer cards with stationary circle indicator

* don't include duplicate lemma choices

* use constructID and show lemma/emoji on choice cards

add method to clear cache in case the results was an error, and add a retry button on error

* gain xp immediately and take out continue session

also refactor the choice cards to have separate widgets for each type and a parent widget to give each an id for xp sparkle

* add practice finished page with analytics

* Color tweaks on completed page and time card placeholder

* add timer

* give XP for bonuses and change timer to use stopwatch

* simplify card logic, lock practice when few vocab words

* merge analytics changes and fix bugs

* reload on language change

- derive XP data from new analytics
- Don't allow any clicks after correct answer selected

* small fixes, added tooltip, added copy to l10

* small tweaks and comments

* formatting and import sorting

---------

Co-authored-by: avashilling <165050625+avashilling@users.noreply.github.com>
2026-01-07 10:13:34 -05:00

133 lines
4.4 KiB
Dart

import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaActivityGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final token = req.targetTokens.first;
final choices = await lemmaActivityDistractors(token);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.lemmaId,
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: choices.map((c) => c.lemma).toSet(),
answers: {token.lemma.text},
),
),
);
}
static Future<Set<ConstructIdentifier>> lemmaActivityDistractors(
PangeaToken token,
) async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab);
final List<ConstructIdentifier> constructIds = constructs.keys.toList();
// Offload computation to an isolate
final Map<ConstructIdentifier, int> distances =
await compute(_computeDistancesInIsolate, {
'lemmas': constructIds,
'target': token.lemma.text,
});
// Sort lemmas by distance
final sortedLemmas = distances.keys.toList()
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
// Skip the first 7 lemmas (to avoid very similar and conjugated forms of verbs) if we have enough lemmas
final int startIndex = sortedLemmas.length > 11 ? 7 : 0;
// Take up to 4 lemmas ensuring uniqueness by lemma text
final List<ConstructIdentifier> uniqueByLemma = [];
for (int i = startIndex; i < sortedLemmas.length; i++) {
final cid = sortedLemmas[i];
if (!uniqueByLemma.any((c) => c.lemma == cid.lemma)) {
uniqueByLemma.add(cid);
if (uniqueByLemma.length == 4) break;
}
}
if (uniqueByLemma.isEmpty) {
return {token.vocabConstructID};
}
// Ensure the target lemma (token.vocabConstructID) is included while keeping unique lemma texts
final int existingIndex = uniqueByLemma
.indexWhere((c) => c.lemma == token.vocabConstructID.lemma);
if (existingIndex >= 0) {
uniqueByLemma[existingIndex] = token.vocabConstructID;
} else {
if (uniqueByLemma.length < 4) {
uniqueByLemma.add(token.vocabConstructID);
} else {
uniqueByLemma[uniqueByLemma.length - 1] = token.vocabConstructID;
}
}
//shuffle so correct answer isn't always first
uniqueByLemma.shuffle();
return uniqueByLemma.toSet();
}
// isolate helper function
static Map<ConstructIdentifier, int> _computeDistancesInIsolate(
Map<String, dynamic> params,
) {
final List<ConstructIdentifier> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<ConstructIdentifier, int> distances = {};
for (final lemma in lemmas) {
distances[lemma] = _levenshteinDistanceSync(target, lemma.lemma);
}
return distances;
}
static int _levenshteinDistanceSync(String s, String t) {
final int m = s.length;
final int n = t.length;
final List<List<int>> dp = List.generate(
m + 1,
(_) => List.generate(n + 1, (_) => 0),
);
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) {
if (i == 0) {
dp[i][j] = j;
} else if (j == 0) {
dp[i][j] = i;
} else if (s[i - 1] == t[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = 1 +
[dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]]
.reduce((a, b) => a < b ? a : b);
}
}
}
return dp[m][n];
}
}