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>
This commit is contained in:
ggurdin 2026-01-07 10:13:34 -05:00 committed by GitHub
parent 5aa3d759e0
commit af395d0aeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2322 additions and 81 deletions

View file

@ -59,6 +59,7 @@ import 'package:fluffychat/pangea/login/pages/signup.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
import 'package:fluffychat/pangea/spaces/space_constants.dart';
import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/widgets/config_viewer.dart';
import 'package:fluffychat/widgets/layouts/empty_page.dart';
import 'package:fluffychat/widgets/layouts/two_column_layout.dart';
@ -573,6 +574,16 @@ abstract class AppRoutes {
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: 'practice',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
const VocabPractice(),
);
},
),
GoRoute(
path: ':construct',
pageBuilder: (context, state) {

View file

@ -5008,6 +5008,16 @@
}
}
},
"constructUseCorLMDesc": "Correct vocab definition practice",
"constructUseIncLMDesc": "Incorrect vocab definition practice",
"constructUseCorLADesc": "Correct vocab audio practice",
"constructUseIncLADesc": "Incorrect vocab audio practice",
"constructUseBonus": "Bonus during vocab practice",
"practiceVocab": "Practice vocabulary",
"selectMeaning": "Select the meaning",
"selectAudio": "Select the matching audio",
"congratulations": "Congratulations!",
"anotherRound": "Another round",
"ssoDialogTitle": "Waiting for sign in to complete",
"ssoDialogDesc": "We opened a new tab so you can sign in securely.",
"ssoDialogHelpText": "🤔 If you didn't see the new tab, please check your popup blocker.",
@ -5016,6 +5026,9 @@
"recordingPermissionDenied": "Permission denied. Enable recording permissions to record audio messages.",
"genericWebRecordingError": "Something went wrong. We recommend using the Chrome browser when recording messages.",
"screenSizeWarning": "For the best experience using this application, please expand your screen size.",
"noActivityRequest": "No current activity request.",
"quit": "Quit",
"congratulationsYouveCompletedPractice": "Congratulations! You've completed the practice session.",
"noSavedActivitiesYet": "Activities will appear here once they are completed and saved.",
"practiceActivityCompleted": "Practice activity completed",
"changeCourse": "Change course",
@ -5026,5 +5039,7 @@
"announcementsChatDesc": "Only space admin can post.",
"inOngoingActivity": "You have an ongoing activity!",
"activitiesToUnlockTopicTitle": "Activities to Unlock Next Topic",
"activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic"
"activitiesToUnlockTopicDesc": "Set the number of activities to unlock the next course topic",
"mustHave10Words" : "You must have at least 10 vocab words to practice them. Try talking to a friend or Pangea Bot to discover more!"
}

View file

@ -53,7 +53,7 @@ class AnalyticsDataService {
AnalyticsSyncController? _syncController;
final ConstructMergeTable _mergeTable = ConstructMergeTable();
Completer<void> _initCompleter = Completer<void>();
Completer<void> initCompleter = Completer<void>();
AnalyticsDataService(Client client) {
updateDispatcher = AnalyticsUpdateDispatcher(this);
@ -77,7 +77,7 @@ class AnalyticsDataService {
return _analyticsClient!;
}
bool get isInitializing => !_initCompleter.isCompleted;
bool get isInitializing => !initCompleter.isCompleted;
Future<Room?> getAnalyticsRoom(LanguageModel l2) =>
_analyticsClientGetter.client.getMyAnalyticsRoom(l2);
@ -155,7 +155,7 @@ class AnalyticsDataService {
Logs().e("Error initializing analytics: $e, $s");
} finally {
Logs().i("Analytics database initialized.");
_initCompleter.complete();
initCompleter.complete();
updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([]));
}
}
@ -173,7 +173,7 @@ class AnalyticsDataService {
Future<void> reinitialize() async {
Logs().i("Reinitializing analytics database.");
_initCompleter = Completer<void>();
initCompleter = Completer<void>();
await _clearDatabase();
await _initDatabase(_analyticsClientGetter.client);
}
@ -192,7 +192,7 @@ class AnalyticsDataService {
}
Future<void> _ensureInitialized() =>
_initCompleter.isCompleted ? Future.value() : _initCompleter.future;
initCompleter.isCompleted ? Future.value() : initCompleter.future;
int numConstructs(ConstructTypeEnum type) =>
_mergeTable.uniqueConstructsByType(type);

View file

@ -2,6 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_analytics_list_view.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_details_view.dart';
@ -179,6 +182,72 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
),
),
),
floatingActionButton:
widget.view == ConstructTypeEnum.vocab && widget.construct == null
? _buildVocabPracticeButton(context)
: null,
);
}
}
Widget _buildVocabPracticeButton(BuildContext context) {
// Check if analytics is loaded first
if (MatrixState
.pangeaController.matrixState.analyticsDataService.isInitializing) {
return FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Loading vocabulary data...',
),
behavior: SnackBarBehavior.floating,
),
);
},
label: Text(L10n.of(context).practiceVocab),
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
);
}
final vocabCount = MatrixState
.pangeaController.matrixState.analyticsDataService
.numConstructs(ConstructTypeEnum.vocab);
final hasEnoughVocab = vocabCount >= 10;
return FloatingActionButton.extended(
onPressed: hasEnoughVocab
? () {
context.go(
"/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice",
);
}
: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).mustHave10Words,
),
behavior: SnackBarBehavior.floating,
),
);
},
backgroundColor:
hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer,
foregroundColor: hasEnoughVocab
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!hasEnoughVocab) ...[
const Icon(Icons.lock_outline, size: 18),
const SizedBox(width: 4),
],
Text(L10n.of(context).practiceVocab),
],
),
);
}

View file

@ -69,8 +69,19 @@ enum ConstructUseTypeEnum {
/// lemma collected by clicking on it
click,
/// Bonus XP, ignored by level analytics page
bonus,
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
nan
nan,
// vocab lemma definition activity
corLM,
incLM,
// vocab lemma audio activity
corLA,
incLA,
}
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
@ -140,8 +151,18 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return L10n.of(context).constructUseIgnMmDesc;
case ConstructUseTypeEnum.click:
return L10n.of(context).constructUseCollected;
case ConstructUseTypeEnum.bonus:
return L10n.of(context).constructUseBonus;
case ConstructUseTypeEnum.nan:
return L10n.of(context).constructUseNanDesc;
case ConstructUseTypeEnum.corLM:
return L10n.of(context).constructUseCorLMDesc;
case ConstructUseTypeEnum.incLM:
return L10n.of(context).constructUseIncLMDesc;
case ConstructUseTypeEnum.corLA:
return L10n.of(context).constructUseCorLADesc;
case ConstructUseTypeEnum.incLA:
return L10n.of(context).constructUseIncLADesc;
}
}
@ -162,10 +183,14 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.incLM:
return ActivityTypeEnum.wordMeaning.icon;
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.incLA:
return ActivityTypeEnum.wordFocusListening.icon;
case ConstructUseTypeEnum.incHWL:
case ConstructUseTypeEnum.ignHWL:
@ -192,6 +217,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return Icons.help;
case ConstructUseTypeEnum.click:
return Icons.format_color_text;
case ConstructUseTypeEnum.bonus:
return Icons.star;
}
}
@ -206,6 +233,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corM:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
return 5;
case ConstructUseTypeEnum.pvm:
@ -223,6 +252,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corIt:
case ConstructUseTypeEnum.corMM:
case ConstructUseTypeEnum.bonus:
return 1;
case ConstructUseTypeEnum.ignIt:
@ -249,6 +279,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.incHWL:
case ConstructUseTypeEnum.incL:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
return -2;
}
}
@ -289,6 +321,11 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.em:
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.nan:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.bonus:
return false;
}
}
@ -313,6 +350,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corHWL:
case ConstructUseTypeEnum.ignHWL:
case ConstructUseTypeEnum.incHWL:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.incLA:
return LearningSkillsEnum.hearing;
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.ignPA:
@ -328,9 +367,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignMM:
case ConstructUseTypeEnum.em:
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.incLM:
return LearningSkillsEnum.reading;
case ConstructUseTypeEnum.pvm:
return LearningSkillsEnum.speaking;
case ConstructUseTypeEnum.bonus:
case ConstructUseTypeEnum.nan:
return LearningSkillsEnum.other;
}
@ -354,6 +396,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corM:
case ConstructUseTypeEnum.em:
case ConstructUseTypeEnum.corMM:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
return SpaceAnalyticsSummaryEnum.numChoicesCorrect;
case ConstructUseTypeEnum.incIt:
@ -364,6 +408,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incL:
case ConstructUseTypeEnum.incM:
case ConstructUseTypeEnum.incMM:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
return SpaceAnalyticsSummaryEnum.numChoicesIncorrect;
case ConstructUseTypeEnum.ignIt:
@ -375,6 +421,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.ignMM:
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.bonus:
case ConstructUseTypeEnum.nan:
return null;
}

View file

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
class LevelPopupProgressBar extends StatefulWidget {
@ -35,8 +34,6 @@ class LevelPopupProgressBarState extends State<LevelPopupProgressBar> {
return AnimatedProgressBar(
height: widget.height,
widthPercent: width,
barColor: AppConfig.goldLight,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
duration: widget.duration,
);
}

View file

@ -10,14 +10,14 @@ class AnimatedProgressBar extends StatelessWidget {
final double widthPercent;
final Color barColor;
final Color backgroundColor;
final Color? backgroundColor;
final Duration? duration;
const AnimatedProgressBar({
required this.height,
required this.widthPercent,
required this.barColor,
required this.backgroundColor,
this.barColor = AppConfig.goldLight,
this.backgroundColor,
this.duration,
super.key,
});
@ -38,7 +38,8 @@ class AnimatedProgressBar extends StatelessWidget {
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
color: backgroundColor,
color: backgroundColor ??
Theme.of(context).colorScheme.secondaryContainer,
),
),
),

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -71,6 +72,8 @@ abstract class AsyncLoader<T> {
T? get value => isLoaded ? (state.value as AsyncLoaded<T>).value : null;
final Completer<T> completer = Completer<T>();
void dispose() {
_disposed = true;
state.dispose();
@ -79,7 +82,7 @@ abstract class AsyncLoader<T> {
Future<T> fetch();
Future<void> load() async {
if (state.value is AsyncLoading || state.value is AsyncLoaded) {
if (state.value is AsyncLoaded) {
// If already loading or loaded, do nothing.
return;
}
@ -90,19 +93,19 @@ abstract class AsyncLoader<T> {
final result = await fetch();
if (_disposed) return;
state.value = AsyncState.loaded(result);
} on HttpException catch (e) {
completer.complete(result);
} catch (e, s) {
completer.completeError(e);
if (!_disposed) {
state.value = AsyncState.error(e);
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {},
);
if (!_disposed) {
state.value = AsyncState.error(e);
if (e is! HttpException) {
ErrorHandler.logError(
e: e,
s: s,
data: {},
);
}
}
}

View file

@ -31,6 +31,7 @@ enum InstructionsEnum {
noSavedActivitiesYet,
setLemmaEmoji,
disableLanguageTools,
selectMeaning,
}
extension InstructionsEnumExtension on InstructionsEnum {
@ -41,6 +42,7 @@ extension InstructionsEnumExtension on InstructionsEnum {
case InstructionsEnum.ttsDisabled:
return l10n.ttsDisbledTitle;
case InstructionsEnum.chooseWordAudio:
case InstructionsEnum.selectMeaning:
case InstructionsEnum.chooseEmoji:
case InstructionsEnum.activityPlannerOverview:
case InstructionsEnum.speechToText:
@ -125,6 +127,8 @@ extension InstructionsEnumExtension on InstructionsEnum {
return "";
case InstructionsEnum.disableLanguageTools:
return l10n.disableLanguageToolsDesc;
case InstructionsEnum.selectMeaning:
return l10n.selectMeaning;
}
}

View file

@ -83,6 +83,16 @@ class LemmaInfoRepo {
}
}
///clear cache of a specific request to retry if failed
static void clearCache(LemmaInfoRequest request) {
final key = request.hashCode.toString();
_cache.remove(key);
}
static void clearAllCache() {
_cache.clear();
}
static Future<Result<LemmaInfoResponse>> _safeFetch(
String token,
LemmaInfoRequest request,

View file

@ -11,7 +11,9 @@ enum ActivityTypeEnum {
lemmaId,
emoji,
morphId,
messageMeaning;
messageMeaning,
lemmaMeaning,
lemmaAudio;
bool get includeTTSOnClick {
switch (this) {
@ -23,11 +25,13 @@ enum ActivityTypeEnum {
return false;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaAudio:
case ActivityTypeEnum.lemmaMeaning:
return true;
}
}
ActivityTypeEnum fromString(String value) {
static ActivityTypeEnum fromString(String value) {
final split = value.split('.').last;
switch (split) {
// used to be called multiple_choice, but we changed it to word_meaning
@ -52,6 +56,12 @@ enum ActivityTypeEnum {
return ActivityTypeEnum.morphId;
case 'message_meaning':
return ActivityTypeEnum.messageMeaning; // TODO: Add to L10n
case 'lemma_meaning':
case 'lemmaMeaning':
return ActivityTypeEnum.lemmaMeaning;
case 'lemma_audio':
case 'lemmaAudio':
return ActivityTypeEnum.lemmaAudio;
default:
throw Exception('Unknown activity type: $split');
}
@ -96,7 +106,17 @@ enum ActivityTypeEnum {
ConstructUseTypeEnum.corMM,
ConstructUseTypeEnum.incMM,
ConstructUseTypeEnum.ignMM,
]; // TODO: Add to L10n
];
case ActivityTypeEnum.lemmaAudio:
return [
ConstructUseTypeEnum.corLA,
ConstructUseTypeEnum.incLA,
];
case ActivityTypeEnum.lemmaMeaning:
return [
ConstructUseTypeEnum.corLM,
ConstructUseTypeEnum.incLM,
];
}
}
@ -116,6 +136,10 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.corM;
case ActivityTypeEnum.messageMeaning:
return ConstructUseTypeEnum.corMM;
case ActivityTypeEnum.lemmaAudio:
return ConstructUseTypeEnum.corLA;
case ActivityTypeEnum.lemmaMeaning:
return ConstructUseTypeEnum.corLM;
}
}
@ -135,15 +159,21 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.incM;
case ActivityTypeEnum.messageMeaning:
return ConstructUseTypeEnum.incMM;
case ActivityTypeEnum.lemmaAudio:
return ConstructUseTypeEnum.incLA;
case ActivityTypeEnum.lemmaMeaning:
return ConstructUseTypeEnum.incLM;
}
}
IconData get icon {
switch (this) {
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.lemmaMeaning:
return Icons.translate;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaAudio:
return Icons.volume_up;
case ActivityTypeEnum.lemmaId:
return Symbols.dictionary;
@ -168,6 +198,8 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.morphId:
case ActivityTypeEnum.messageMeaning:
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.lemmaAudio:
return 1;
}
}

View file

@ -3,6 +3,7 @@ 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';
@ -17,7 +18,7 @@ class LemmaActivityGenerator {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final token = req.targetTokens.first;
final choices = await _lemmaActivityDistractors(token);
final choices = await lemmaActivityDistractors(token);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
@ -26,27 +27,25 @@ class LemmaActivityGenerator {
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: choices,
choices: choices.map((c) => c.lemma).toSet(),
answers: {token.lemma.text},
),
),
);
}
static Future<Set<String>> _lemmaActivityDistractors(
static Future<Set<ConstructIdentifier>> lemmaActivityDistractors(
PangeaToken token,
) async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab);
final List<String> lemmas =
constructs.values.map((c) => c.lemma).toSet().toList();
final List<ConstructIdentifier> constructIds = constructs.keys.toList();
// Offload computation to an isolate
final Map<String, int> distances =
final Map<ConstructIdentifier, int> distances =
await compute(_computeDistancesInIsolate, {
'lemmas': lemmas,
'lemmas': constructIds,
'target': token.lemma.text,
});
@ -54,29 +53,53 @@ class LemmaActivityGenerator {
final sortedLemmas = distances.keys.toList()
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
// Take the shortest 4
final choices = sortedLemmas.take(4).toSet();
if (choices.isEmpty) {
return {token.lemma.text};
// 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 (!choices.contains(token.lemma.text)) {
choices.add(token.lemma.text);
if (uniqueByLemma.isEmpty) {
return {token.vocabConstructID};
}
return choices;
// 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<String, int> _computeDistancesInIsolate(
static Map<ConstructIdentifier, int> _computeDistancesInIsolate(
Map<String, dynamic> params,
) {
final List<String> lemmas = params['lemmas'];
final List<ConstructIdentifier> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> distances = {};
final Map<ConstructIdentifier, int> distances = {};
for (final lemma in lemmas) {
distances[lemma] = _levenshteinDistanceSync(target, lemma);
distances[lemma] = _levenshteinDistanceSync(target, lemma.lemma);
}
return distances;
}

View file

@ -51,9 +51,6 @@ class MessageActivityRequest {
final String userL1;
final String userL2;
final String messageText;
final List<PangeaToken> messageTokens;
final List<PangeaToken> targetTokens;
final ActivityTypeEnum targetType;
final MorphFeaturesEnum? targetMorphFeature;
@ -63,8 +60,6 @@ class MessageActivityRequest {
MessageActivityRequest({
required this.userL1,
required this.userL2,
required this.messageText,
required this.messageTokens,
required this.activityQualityFeedback,
required this.targetTokens,
required this.targetType,
@ -79,8 +74,6 @@ class MessageActivityRequest {
return {
'user_l1': userL1,
'user_l2': userL2,
'message_text': messageText,
'message_tokens': messageTokens.map((e) => e.toJson()).toList(),
'activity_quality_feedback': activityQualityFeedback?.toJson(),
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
'target_type': targetType.name,
@ -93,7 +86,6 @@ class MessageActivityRequest {
if (identical(this, other)) return true;
return other is MessageActivityRequest &&
other.messageText == messageText &&
other.targetType == targetType &&
other.activityQualityFeedback?.feedbackText ==
activityQualityFeedback?.feedbackText &&
@ -103,8 +95,7 @@ class MessageActivityRequest {
@override
int get hashCode {
return messageText.hashCode ^
targetType.hashCode ^
return targetType.hashCode ^
activityQualityFeedback.hashCode ^
targetTokens.hashCode ^
targetMorphFeature.hashCode;

View file

@ -53,8 +53,7 @@ class PracticeActivityModel {
);
bool onMultipleChoiceSelect(
PangeaToken token,
PracticeChoice choice,
String choiceContent,
) {
if (multipleChoiceContent == null) {
debugger(when: kDebugMode);
@ -68,24 +67,23 @@ class PracticeActivityModel {
if (practiceTarget.isComplete ||
practiceTarget.record.alreadyHasMatchResponse(
choice.form.cId,
choice.choiceContent,
targetTokens.first.vocabConstructID,
choiceContent,
)) {
// the user has already selected this choice
// so we don't want to record it again
return false;
}
final bool isCorrect =
multipleChoiceContent!.isCorrect(choice.choiceContent);
final bool isCorrect = multipleChoiceContent!.isCorrect(choiceContent);
// NOTE: the response is associated with the contructId of the choice, not the selected token
// example: the user selects the word "cat" to match with the emoji 🐶
// the response is associated with correct word "dog", not the word "cat"
practiceTarget.record.addResponse(
cId: choice.form.cId,
cId: targetTokens.first.vocabConstructID,
target: practiceTarget,
text: choice.choiceContent,
text: choiceContent,
score: isCorrect ? 1 : 0,
);
@ -165,8 +163,7 @@ class PracticeActivityModel {
return PracticeActivityModel(
langCode: json['lang_code'] as String,
activityType:
ActivityTypeEnum.wordMeaning.fromString(json['activity_type']),
activityType: ActivityTypeEnum.fromString(json['activity_type']),
multipleChoiceContent: json['content'] != null
? MultipleChoiceActivity.fromJson(contentMap)
: null,

View file

@ -21,6 +21,8 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d
import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Represents an item in the completion cache.
@ -70,7 +72,7 @@ class PracticeRepo {
messageInfo: messageInfo,
);
_setCached(req, res);
await _setCached(req, res);
return Result.value(res.activity);
} on HttpException catch (e, s) {
return Result.error(e, s);
@ -119,6 +121,10 @@ class PracticeRepo {
return EmojiActivityGenerator.get(req, messageInfo: messageInfo);
case ActivityTypeEnum.lemmaId:
return LemmaActivityGenerator.get(req);
case ActivityTypeEnum.lemmaMeaning:
return VocabMeaningActivityGenerator.get(req);
case ActivityTypeEnum.lemmaAudio:
return VocabAudioActivityGenerator.get(req);
case ActivityTypeEnum.morphId:
return MorphActivityGenerator.get(req);
case ActivityTypeEnum.wordMeaning:
@ -161,16 +167,15 @@ class PracticeRepo {
return null;
}
static void _setCached(
static Future<void> _setCached(
MessageActivityRequest req,
MessageActivityResponse res,
) {
_storage.write(
req.hashCode.toString(),
_RequestCacheItem(
practiceActivity: res.activity,
timestamp: DateTime.now(),
).toJson(),
);
}
) =>
_storage.write(
req.hashCode.toString(),
_RequestCacheItem(
practiceActivity: res.activity,
timestamp: DateTime.now(),
).toJson(),
);
}

View file

@ -100,9 +100,6 @@ class PracticeController with ChangeNotifier {
final req = MessageActivityRequest(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
messageText: pangeaMessageEvent.messageDisplayText,
messageTokens:
pangeaMessageEvent.messageDisplayRepresentation?.tokens ?? [],
activityQualityFeedback: null,
targetTokens: target.tokens,
targetType: target.activityType,
@ -156,7 +153,9 @@ class PracticeController with ChangeNotifier {
if (_activity == null) return;
final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId
? _activity!.onMultipleChoiceSelect(token, choice)
? _activity!.onMultipleChoiceSelect(
choice.choiceContent,
)
: _activity!.onMatch(token, choice);
final targetId =

View file

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Displays an audio button with a select label in a row layout
/// TODO: needs a better design and button handling
class AudioChoiceCard extends StatelessWidget {
final String text;
final VoidCallback onPressed;
final bool isCorrect;
final double height;
final bool isEnabled;
const AudioChoiceCard({
required this.text,
required this.onPressed,
required this.isCorrect,
this.height = 72.0,
this.isEnabled = true,
super.key,
});
@override
Widget build(BuildContext context) {
return GameChoiceCard(
shouldFlip: false,
transformId: text,
onPressed: onPressed,
isCorrect: isCorrect,
height: height,
isEnabled: isEnabled,
child: Row(
children: [
Expanded(
child: WordAudioButton(
text: text,
uniqueID: "vocab_practice_choice_$text",
langCode:
MatrixState.pangeaController.userController.userL2!.langCode,
),
),
Text(L10n.of(context).select),
],
),
);
}
}

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// A unified choice card that handles flipping, color tinting, hovering, and alt widgets
class GameChoiceCard extends StatefulWidget {
final Widget child;
final Widget? altChild;
final VoidCallback onPressed;
final bool isCorrect;
final double height;
final bool shouldFlip;
final String? transformId;
final bool isEnabled;
const GameChoiceCard({
required this.child,
this.altChild,
required this.onPressed,
required this.isCorrect,
this.height = 72.0,
this.shouldFlip = false,
this.transformId,
this.isEnabled = true,
super.key,
});
@override
State<GameChoiceCard> createState() => _GameChoiceCardState();
}
class _GameChoiceCardState extends State<GameChoiceCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
bool _flipped = false;
bool _isHovered = false;
bool _useAltChild = false;
bool _clicked = false;
@override
void initState() {
super.initState();
if (widget.shouldFlip) {
_controller = AnimationController(
duration: const Duration(milliseconds: 220),
vsync: this,
);
_scaleAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_controller.addListener(_onAnimationUpdate);
}
}
void _onAnimationUpdate() {
// Swap to altChild when card is almost fully shrunk
if (_controller.value >= 0.95 && !_useAltChild && widget.altChild != null) {
setState(() => _useAltChild = true);
}
// Mark as flipped when card is fully shrunk
if (_controller.value >= 0.95 && !_flipped) {
setState(() => _flipped = true);
}
}
@override
void dispose() {
if (widget.shouldFlip) {
_controller.removeListener(_onAnimationUpdate);
_controller.dispose();
}
super.dispose();
}
Future<void> _handleTap() async {
if (!widget.isEnabled) return;
if (widget.shouldFlip) {
if (_flipped) return;
// Animate forward (shrink), then reverse (expand)
await _controller.forward();
await _controller.reverse();
} else {
if (_clicked) return;
setState(() => _clicked = true);
}
widget.onPressed();
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
final Color baseColor = colorScheme.surfaceContainerHighest;
final Color hoverColor = colorScheme.onSurface.withValues(alpha: 0.08);
final Color tintColor = widget.isCorrect
? AppConfig.success.withValues(alpha: 0.3)
: AppConfig.error.withValues(alpha: 0.3);
Widget card = MouseRegion(
onEnter:
widget.isEnabled ? ((_) => setState(() => _isHovered = true)) : null,
onExit:
widget.isEnabled ? ((_) => setState(() => _isHovered = false)) : null,
child: SizedBox(
width: double.infinity,
height: widget.height,
child: GestureDetector(
onTap: _handleTap,
child: widget.shouldFlip
? AnimatedBuilder(
animation: _scaleAnim,
builder: (context, child) {
final bool showContent = _scaleAnim.value > 0.1;
return Transform.scale(
scaleY: _scaleAnim.value,
child: Container(
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),
),
foregroundDecoration: BoxDecoration(
color: _flipped
? tintColor
: (_isHovered ? hoverColor : Colors.transparent),
borderRadius: BorderRadius.circular(16),
),
margin: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 0,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: widget.height,
alignment: Alignment.center,
child: Opacity(
opacity: showContent ? 1.0 : 0.0,
child: _useAltChild && widget.altChild != null
? widget.altChild!
: widget.child,
),
),
);
},
)
: Container(
decoration: BoxDecoration(
color: baseColor,
borderRadius: BorderRadius.circular(16),
),
foregroundDecoration: BoxDecoration(
color: _clicked
? tintColor
: (_isHovered ? hoverColor : Colors.transparent),
borderRadius: BorderRadius.circular(16),
),
margin:
const EdgeInsets.symmetric(vertical: 6, horizontal: 0),
padding: const EdgeInsets.symmetric(horizontal: 16),
height: widget.height,
alignment: Alignment.center,
child: widget.child,
),
),
),
);
// Wrap with transform target if transformId is provided
if (widget.transformId != null) {
final transformTargetId =
'vocab-choice-card-${widget.transformId!.replaceAll(' ', '_')}';
card = CompositedTransformTarget(
link: MatrixState.pAnyState.layerLinkAndKey(transformTargetId).link,
child: card,
);
}
return card;
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
/// Choice card for meaning activity with emoji, and alt text on flip
class MeaningChoiceCard extends StatelessWidget {
final String choiceId;
final String displayText;
final String? emoji;
final VoidCallback onPressed;
final bool isCorrect;
final double height;
final bool isEnabled;
const MeaningChoiceCard({
required this.choiceId,
required this.displayText,
this.emoji,
required this.onPressed,
required this.isCorrect,
this.height = 72.0,
this.isEnabled = true,
super.key,
});
@override
Widget build(BuildContext context) {
final baseTextSize =
(Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) *
(height / 72.0).clamp(1.0, 1.4);
final emojiSize = baseTextSize * 1.2;
return GameChoiceCard(
shouldFlip: true,
transformId: choiceId,
onPressed: onPressed,
isCorrect: isCorrect,
height: height,
isEnabled: isEnabled,
altChild: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (emoji != null && emoji!.isNotEmpty)
SizedBox(
width: height * .7,
height: height,
child: Center(
child: Text(
emoji!,
style: TextStyle(fontSize: emojiSize),
),
),
),
Expanded(
child: Text(
ConstructIdentifier.fromString(choiceId)!.lemma,
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: baseTextSize,
),
),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (emoji != null && emoji!.isNotEmpty)
SizedBox(
width: height * .7,
height: height,
child: Center(
child: Text(
emoji!,
style: TextStyle(fontSize: emojiSize),
),
),
),
Expanded(
child: Text(
displayText,
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: baseTextSize,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/stat_card.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class CompletedActivitySessionView extends StatefulWidget {
final VocabPracticeState controller;
const CompletedActivitySessionView(this.controller, {super.key});
@override
State<CompletedActivitySessionView> createState() =>
_CompletedActivitySessionViewState();
}
class _CompletedActivitySessionViewState
extends State<CompletedActivitySessionView> {
late final Future<Map<String, double>> progressChangeFuture;
double currentProgress = 0.0;
Uri? avatarUrl;
bool shouldShowRain = false;
@override
void initState() {
super.initState();
// Fetch avatar URL
final client = Matrix.of(context).client;
client.fetchOwnProfile().then((profile) {
if (mounted) {
setState(() => avatarUrl = profile.avatarUrl);
}
});
progressChangeFuture = widget.controller.calculateProgressChange(
widget.controller.sessionLoader.value!.totalXpGained,
);
}
void _onProgressChangeLoaded(Map<String, double> progressChange) {
//start with before progress
currentProgress = progressChange['before'] ?? 0.0;
//switch to after progress after first frame, to activate animation
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
currentProgress = progressChange['after'] ?? 0.0;
// Start the star rain
shouldShowRain = true;
});
}
});
}
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final username =
Matrix.of(context).client.userID?.split(':').first.substring(1) ?? '';
final bool accuracyAchievement =
widget.controller.sessionLoader.value!.accuracy == 100;
final bool timeAchievement =
widget.controller.sessionLoader.value!.elapsedSeconds <= 60;
final int numBonusPoints = widget
.controller.sessionLoader.value!.completedUses
.where((use) => use.xp > 0)
.length;
//give double bonus for both, single for one, none for zero
final int bonusXp = (accuracyAchievement && timeAchievement)
? numBonusPoints * 2
: (accuracyAchievement || timeAchievement)
? numBonusPoints
: 0;
return FutureBuilder<Map<String, double>>(
future: progressChangeFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
// Initialize progress when data is available
if (currentProgress == 0.0 && !shouldShowRain) {
_onProgressChangeLoaded(snapshot.data!);
}
return Stack(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16, right: 16, left: 16),
child: Column(
children: [
Text(
L10n.of(context).congratulationsYouveCompletedPractice,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: avatarUrl == null
? Avatar(
name: username,
showPresence: false,
size: 100,
)
: ClipOval(
child: MxcImage(
uri: avatarUrl,
width: 100,
height: 100,
),
),
),
Column(
children: [
Padding(
padding: const EdgeInsets.only(
top: 16.0,
bottom: 16.0,
),
child: AnimatedProgressBar(
height: 20.0,
widthPercent: currentProgress,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
duration: const Duration(milliseconds: 500),
),
),
Text(
"+ ${widget.controller.sessionLoader.value!.totalXpGained + bonusXp} XP",
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
color: AppConfig.goldLight,
fontWeight: FontWeight.bold,
),
),
],
),
StatCard(
icon: Icons.my_location,
text:
"${L10n.of(context).accuracy}: ${widget.controller.sessionLoader.value!.accuracy}%",
isAchievement: accuracyAchievement,
achievementText: "+ $numBonusPoints XP",
child: PercentMarkerBar(
height: 20.0,
widthPercent: widget
.controller.sessionLoader.value!.accuracy /
100.0,
markerWidth: 20.0,
markerColor: AppConfig.success,
backgroundColor: !(widget.controller.sessionLoader
.value!.accuracy ==
100)
? Theme.of(context)
.colorScheme
.surfaceContainerHighest
: Color.alphaBlend(
AppConfig.goldLight.withValues(alpha: 0.3),
Theme.of(context)
.colorScheme
.surfaceContainerHighest,
),
),
),
StatCard(
icon: Icons.alarm,
text:
"${L10n.of(context).time}: ${_formatTime(widget.controller.sessionLoader.value!.elapsedSeconds)}",
isAchievement: timeAchievement,
achievementText: "+ $numBonusPoints XP",
child: TimeStarsWidget(
elapsedSeconds: widget
.controller.sessionLoader.value!.elapsedSeconds,
timeForBonus: widget
.controller.sessionLoader.value!.timeForBonus,
),
),
Column(
children: [
//expanded row button
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () =>
widget.controller.reloadSession(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).anotherRound,
),
],
),
),
const SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 8.0,
),
),
onPressed: () => Navigator.of(context).pop(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
L10n.of(context).quit,
),
],
),
),
],
),
],
),
),
],
),
),
if (shouldShowRain)
const StarRainWidget(
showBlast: true,
rainDuration: Duration(seconds: 5),
),
],
);
},
);
}
}
class TimeStarsWidget extends StatelessWidget {
final int elapsedSeconds;
final int timeForBonus;
const TimeStarsWidget({
required this.elapsedSeconds,
required this.timeForBonus,
super.key,
});
int get starCount {
if (elapsedSeconds <= timeForBonus) return 5;
if (elapsedSeconds <= timeForBonus * 1.5) return 4;
if (elapsedSeconds <= timeForBonus * 2) return 3;
if (elapsedSeconds <= timeForBonus * 2.5) return 2;
return 1; // anything above 2.5x timeForBonus
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
5,
(index) => Icon(
index < starCount ? Icons.star : Icons.star_outline,
color: AppConfig.goldLight,
size: 36,
),
),
);
}
}

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
// A progress bar with a rounded marker indicating a percentage position
class PercentMarkerBar extends StatelessWidget {
final double height;
final double widthPercent;
final double markerWidth;
final Color markerColor;
final Color? backgroundColor;
const PercentMarkerBar({
required this.height,
required this.widthPercent,
this.markerWidth = 10.0,
this.markerColor = AppConfig.goldLight,
this.backgroundColor,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final halfMarker = markerWidth / 2;
// Calculate the center position of the marker
final targetPosition = totalWidth * widthPercent.clamp(0.0, 1.0);
// Calculate the start position, clamping to keep marker within bounds
final markerStart =
(targetPosition - halfMarker).clamp(0.0, totalWidth - markerWidth);
return Stack(
alignment: Alignment.centerLeft,
children: [
// Background bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
height: height,
width: constraints.maxWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(height / 2),
color: backgroundColor ??
Theme.of(context).colorScheme.secondaryContainer,
),
),
),
// Marker circle
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
margin: EdgeInsets.only(left: markerStart),
height: height,
width: markerWidth,
decoration: BoxDecoration(
color: markerColor,
borderRadius: BorderRadius.circular(height / 2),
),
),
),
],
);
},
);
}
}

View file

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
class StatCard extends StatelessWidget {
final IconData icon;
final String text;
final String achievementText;
final Widget child;
final bool isAchievement;
const StatCard({
required this.icon,
required this.text,
required this.achievementText,
required this.child,
this.isAchievement = false,
super.key,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final backgroundColor = isAchievement
? Color.alphaBlend(
Theme.of(context).colorScheme.surface.withAlpha(170),
AppConfig.goldLight,
)
: colorScheme.surfaceContainer;
return Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
icon,
),
const SizedBox(width: 8),
Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (isAchievement) ...[
const Spacer(),
Text(
achievementText,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
],
),
const SizedBox(height: 8),
child,
],
),
);
}
}

View file

@ -0,0 +1,29 @@
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.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';
class VocabAudioActivityGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
final token = req.targetTokens.first;
final choices =
await LemmaActivityGenerator.lemmaActivityDistractors(token);
final choicesList = choices.map((c) => c.lemma).toList();
choicesList.shuffle();
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: choicesList.toSet(),
answers: {token.lemma.text},
),
),
);
}
}

View file

@ -0,0 +1,32 @@
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.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';
class VocabMeaningActivityGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
final token = req.targetTokens.first;
final choices =
await LemmaActivityGenerator.lemmaActivityDistractors(token);
if (!choices.contains(token.vocabConstructID)) {
choices.add(token.vocabConstructID);
}
final Set<String> constructIdChoices = choices.map((c) => c.string).toSet();
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: constructIdChoices,
answers: {token.vocabConstructID.string},
),
),
);
}
}

View file

@ -0,0 +1,430 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SessionLoader extends AsyncLoader<VocabPracticeSessionModel> {
@override
Future<VocabPracticeSessionModel> fetch() =>
VocabPracticeSessionRepo.currentSession;
}
class VocabPractice extends StatefulWidget {
const VocabPractice({super.key});
@override
VocabPracticeState createState() => VocabPracticeState();
}
class VocabPracticeState extends State<VocabPractice> {
SessionLoader sessionLoader = SessionLoader();
PracticeActivityModel? currentActivity;
bool isLoadingActivity = true;
bool isAwaitingNextActivity = false;
String? activityError;
bool isLoadingLemmaInfo = false;
final Map<String, String> _choiceTexts = {};
final Map<String, String?> _choiceEmojis = {};
StreamSubscription<void>? _languageStreamSubscription;
bool _sessionClearedDueToLanguageChange = false;
@override
void initState() {
super.initState();
_startSession();
_listenToLanguageChanges();
}
@override
void dispose() {
_languageStreamSubscription?.cancel();
if (isComplete) {
VocabPracticeSessionRepo.clearSession();
} else if (!_sessionClearedDueToLanguageChange) {
//don't save if session was cleared due to language change
_saveCurrentTime();
}
sessionLoader.dispose();
super.dispose();
}
void _saveCurrentTime() {
if (sessionLoader.isLoaded) {
VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
}
}
/// Resets all session state without disposing the widget
void _resetState() {
currentActivity = null;
isLoadingActivity = true;
isAwaitingNextActivity = false;
activityError = null;
isLoadingLemmaInfo = false;
_choiceTexts.clear();
_choiceEmojis.clear();
}
bool get isComplete =>
sessionLoader.isLoaded && sessionLoader.value!.hasCompletedCurrentGroup;
double get progress =>
sessionLoader.isLoaded ? sessionLoader.value!.progress : 0.0;
int get availableActivities => sessionLoader.isLoaded
? sessionLoader.value!.currentAvailableActivities
: 0;
int get completedActivities =>
sessionLoader.isLoaded ? sessionLoader.value!.currentIndex : 0;
int get elapsedSeconds =>
sessionLoader.isLoaded ? sessionLoader.value!.elapsedSeconds : 0;
void updateElapsedTime(int seconds) {
if (sessionLoader.isLoaded) {
sessionLoader.value!.elapsedSeconds = seconds;
}
}
Future<void> _waitForAnalytics() async {
if (!MatrixState.pangeaController.matrixState.analyticsDataService
.initCompleter.isCompleted) {
MatrixState.pangeaController.initControllers();
await MatrixState.pangeaController.matrixState.analyticsDataService
.initCompleter.future;
}
}
void _listenToLanguageChanges() {
_languageStreamSubscription = MatrixState
.pangeaController.userController.languageStream.stream
.listen((_) async {
// If language changed, clear session and back out of vocab practice
if (await _shouldReloadSession()) {
_sessionClearedDueToLanguageChange = true;
await VocabPracticeSessionRepo.clearSession();
if (mounted) {
Navigator.of(context).pop();
}
}
});
}
Future<void> _startSession() async {
await _waitForAnalytics();
await sessionLoader.load();
// If user languages have changed since last session, clear session
if (await _shouldReloadSession()) {
await VocabPracticeSessionRepo.clearSession();
sessionLoader.dispose();
sessionLoader = SessionLoader();
await sessionLoader.load();
}
loadActivity();
}
// check if current l1 and l2 have changed from those of the loaded session
Future<bool> _shouldReloadSession() async {
if (!sessionLoader.isLoaded) return false;
final session = sessionLoader.value!;
final currentL1 =
MatrixState.pangeaController.userController.userL1?.langCode;
final currentL2 =
MatrixState.pangeaController.userController.userL2?.langCode;
if (session.userL1 != currentL1 || session.userL2 != currentL2) {
return true;
}
return false;
}
Future<void> completeActivitySession() async {
if (!sessionLoader.isLoaded) return;
_saveCurrentTime();
sessionLoader.value!.finishSession();
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
setState(() {});
}
Future<void> reloadSession() async {
await showFutureLoadingDialog(
context: context,
future: () async {
// Clear current session storage, dispose old session loader, and clear state variables
await VocabPracticeSessionRepo.clearSession();
sessionLoader.dispose();
sessionLoader = SessionLoader();
_resetState();
await _startSession();
},
);
if (mounted) {
setState(() {});
}
}
Future<List<InlineSpan>?> getExampleMessage(
ConstructIdentifier construct,
) async {
final ConstructUses constructUse = await Matrix.of(context)
.analyticsDataService
.getConstructUse(construct);
for (final use in constructUse.cappedUses) {
if (use.metadata.eventId == null || use.metadata.roomId == null) {
continue;
}
final room = MatrixState.pangeaController.matrixState.client
.getRoomById(use.metadata.roomId!);
if (room == null) continue;
final event = await room.getEventById(use.metadata.eventId!);
if (event == null) continue;
final timeline = await room.getTimeline();
final pangeaMessageEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId ==
MatrixState.pangeaController.matrixState.client.userID,
);
final tokens = pangeaMessageEvent.messageDisplayRepresentation?.tokens;
if (tokens == null || tokens.isEmpty) continue;
final token = tokens.firstWhereOrNull(
(token) => token.text.content == use.form,
);
if (token == null) continue;
final text = pangeaMessageEvent.messageDisplayText;
final tokenText = token.text.content;
int tokenIndex = text.indexOf(tokenText);
if (tokenIndex == -1) continue;
final beforeSubstring = text.substring(0, tokenIndex);
if (beforeSubstring.length != beforeSubstring.characters.length) {
tokenIndex = beforeSubstring.characters.length;
}
final int tokenLength = tokenText.characters.length;
final before = text.characters.take(tokenIndex).toString();
final after = text.characters.skip(tokenIndex + tokenLength).toString();
return [
TextSpan(text: before),
TextSpan(
text: tokenText,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(text: after),
];
}
return null;
}
Future<void> loadActivity() async {
if (!sessionLoader.isLoaded) {
try {
await sessionLoader.completer.future;
} catch (_) {
return;
}
}
if (!mounted) return;
setState(() {
isAwaitingNextActivity = false;
currentActivity = null;
isLoadingActivity = true;
activityError = null;
_choiceTexts.clear();
_choiceEmojis.clear();
});
final session = sessionLoader.value!;
final activityRequest = session.currentActivityRequest;
if (activityRequest == null) {
setState(() {
activityError = L10n.of(context).noActivityRequest;
isLoadingActivity = false;
});
return;
}
final result = await PracticeRepo.getPracticeActivity(
activityRequest,
messageInfo: {},
);
if (result.isError) {
activityError = L10n.of(context).oopsSomethingWentWrong;
} else {
currentActivity = result.result!;
}
// Prefetch lemma info for meaning activities before marking ready
if (currentActivity != null &&
currentActivity!.activityType == ActivityTypeEnum.lemmaMeaning) {
final choices = currentActivity!.multipleChoiceContent!.choices.toList();
await _prefetchLemmaInfo(choices);
}
if (mounted) {
setState(() => isLoadingActivity = false);
}
}
Future<void> onSelectChoice(String choice) async {
if (currentActivity == null) return;
final activity = currentActivity!;
activity.onMultipleChoiceSelect(choice);
final correct = activity.multipleChoiceContent!.isCorrect(choice);
// Submit answer immediately (records use and gives XP)
sessionLoader.value!.submitAnswer(activity, correct);
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
final transformTargetId =
'vocab-choice-card-${choice.replaceAll(' ', '_')}';
if (correct) {
OverlayUtil.showPointsGained(transformTargetId, 5, context);
} else {
OverlayUtil.showPointsGained(transformTargetId, -2, context);
}
if (!correct) return;
// display the fact that the choice was correct before loading the next activity
setState(() => isAwaitingNextActivity = true);
await Future.delayed(const Duration(milliseconds: 1000));
setState(() => isAwaitingNextActivity = false);
// Only move to next activity when answer is correct
sessionLoader.value!.completeActivity(activity);
await VocabPracticeSessionRepo.updateSession(sessionLoader.value!);
if (isComplete) {
await completeActivitySession();
}
await loadActivity();
}
Future<Map<String, double>> calculateProgressChange(int xpGained) async {
final derivedData = await MatrixState
.pangeaController.matrixState.analyticsDataService.derivedData;
final currentLevel = derivedData.level;
final currentXP = derivedData.totalXP;
final minXPForCurrentLevel =
DerivedAnalyticsDataModel.calculateXpWithLevel(currentLevel);
final minXPForNextLevel = derivedData.minXPForNextLevel;
final xpRange = minXPForNextLevel - minXPForCurrentLevel;
final progressBefore =
((currentXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0);
final newTotalXP = currentXP + xpGained;
final progressAfter =
((newTotalXP - minXPForCurrentLevel) / xpRange).clamp(0.0, 1.0);
return {
'before': progressBefore,
'after': progressAfter,
};
}
@override
Widget build(BuildContext context) => VocabPracticeView(this);
String getChoiceText(String choiceId) {
if (_choiceTexts.containsKey(choiceId)) return _choiceTexts[choiceId]!;
final cId = ConstructIdentifier.fromString(choiceId);
return cId?.lemma ?? choiceId;
}
String? getChoiceEmoji(String choiceId) => _choiceEmojis[choiceId];
//fetches display info for all choices from constructIDs
Future<void> _prefetchLemmaInfo(List<String> choiceIds) async {
if (!mounted) return;
setState(() => isLoadingLemmaInfo = true);
final results = await Future.wait(
choiceIds.map((id) async {
final cId = ConstructIdentifier.fromString(id);
if (cId == null) {
return null;
}
try {
final result = await cId.getLemmaInfo({});
return result;
} catch (e) {
return null;
}
}),
);
// Check if any result is an error
for (int i = 0; i < results.length; i++) {
final res = results[i];
if (res != null && res.isError) {
// Clear cache for failed items so retry will fetch fresh
final failedId = choiceIds[i];
final cId = ConstructIdentifier.fromString(failedId);
if (cId != null) {
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
}
if (mounted) {
setState(() {
activityError = L10n.of(context).oopsSomethingWentWrong;
isLoadingLemmaInfo = false;
});
}
return;
}
// Update choice texts/emojis if successful
if (res != null && !res.isError) {
final id = choiceIds[i];
final info = res.result!;
_choiceTexts[id] = info.meaning;
_choiceEmojis[id] = _choiceEmojis[id] ?? info.emoji.firstOrNull;
}
}
if (mounted) {
setState(() => isLoadingLemmaInfo = false);
}
}
}

View file

@ -0,0 +1,253 @@
import 'dart:math';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeSessionModel {
final DateTime startedAt;
final List<ConstructIdentifier> sortedConstructIds;
final List<ActivityTypeEnum> activityTypes;
final String userL1;
final String userL2;
int currentIndex;
int currentGroup;
final List<OneConstructUse> completedUses;
bool finished;
int elapsedSeconds;
VocabPracticeSessionModel({
required this.startedAt,
required this.sortedConstructIds,
required this.activityTypes,
required this.userL1,
required this.userL2,
required this.completedUses,
this.currentIndex = 0,
this.currentGroup = 0,
this.finished = false,
this.elapsedSeconds = 0,
}) : assert(
activityTypes.every(
(t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio}
.contains(t),
),
),
assert(
activityTypes.length == practiceGroupSize,
);
static const int practiceGroupSize = 10;
int get currentAvailableActivities => min(
((currentGroup + 1) * practiceGroupSize),
sortedConstructIds.length,
);
bool get hasCompletedCurrentGroup =>
currentIndex >= currentAvailableActivities;
int get timeForBonus => 60;
double get progress =>
(currentIndex / currentAvailableActivities).clamp(0.0, 1.0);
List<ConstructIdentifier> get currentPracticeGroup => sortedConstructIds
.skip(currentGroup * practiceGroupSize)
.take(practiceGroupSize)
.toList();
ConstructIdentifier? get currentConstructId {
if (currentIndex < 0 || hasCompletedCurrentGroup) {
return null;
}
return currentPracticeGroup[currentIndex % practiceGroupSize];
}
ActivityTypeEnum? get currentActivityType {
if (currentIndex < 0 || hasCompletedCurrentGroup) {
return null;
}
return activityTypes[currentIndex % practiceGroupSize];
}
MessageActivityRequest? get currentActivityRequest {
final constructId = currentConstructId;
if (constructId == null || currentActivityType == null) return null;
final activityType = currentActivityType;
return MessageActivityRequest(
userL1: userL1,
userL2: userL2,
activityQualityFeedback: null,
targetTokens: [
PangeaToken(
lemma: Lemma(
text: constructId.lemma,
saveVocab: true,
form: constructId.lemma,
),
pos: constructId.category,
text: PangeaTokenText.fromString(constructId.lemma),
morph: {},
),
],
targetType: activityType!,
targetMorphFeature: null,
);
}
int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp);
double get accuracy {
if (completedUses.isEmpty) return 0.0;
final correct = completedUses.where((use) => use.xp > 0).length;
final result = correct / completedUses.length;
return (result * 100).truncateToDouble();
}
void finishSession() {
finished = true;
// give bonus XP uses for each construct if earned
if (accuracy >= 100) {
final bonusUses = completedUses
.where((use) => use.xp > 0)
.map(
(use) => OneConstructUse(
useType: ConstructUseTypeEnum.bonus,
constructType: use.constructType,
metadata: ConstructUseMetaData(
roomId: use.metadata.roomId,
timeStamp: DateTime.now(),
),
category: use.category,
lemma: use.lemma,
form: use.form,
xp: ConstructUseTypeEnum.bonus.pointValue,
),
)
.toList();
MatrixState
.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
bonusUses,
);
}
if (elapsedSeconds <= timeForBonus) {
final bonusUses = completedUses
.where((use) => use.xp > 0)
.map(
(use) => OneConstructUse(
useType: ConstructUseTypeEnum.bonus,
constructType: use.constructType,
metadata: ConstructUseMetaData(
roomId: use.metadata.roomId,
timeStamp: DateTime.now(),
),
category: use.category,
lemma: use.lemma,
form: use.form,
xp: ConstructUseTypeEnum.bonus.pointValue,
),
)
.toList();
MatrixState
.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
bonusUses,
);
}
}
void submitAnswer(PracticeActivityModel activity, bool isCorrect) {
final useType = isCorrect
? activity.activityType.correctUse
: activity.activityType.incorrectUse;
final use = OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: activity.targetTokens.first.pos,
lemma: activity.targetTokens.first.lemma.text,
form: activity.targetTokens.first.lemma.text,
xp: useType.pointValue,
);
completedUses.add(use);
// Give XP immediately
MatrixState.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
null,
[use],
);
}
void completeActivity(PracticeActivityModel activity) {
currentIndex += 1;
}
factory VocabPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return VocabPracticeSessionModel(
startedAt: DateTime.parse(json['startedAt'] as String),
sortedConstructIds: (json['sortedConstructIds'] as List<dynamic>)
.map((e) => ConstructIdentifier.fromJson(e))
.whereType<ConstructIdentifier>()
.toList(),
activityTypes: (json['activityTypes'] as List<dynamic>)
.map(
(e) => ActivityTypeEnum.values.firstWhere(
(at) => at.name == (e as String),
),
)
.whereType<ActivityTypeEnum>()
.toList(),
userL1: json['userL1'] as String,
userL2: json['userL2'] as String,
currentIndex: json['currentIndex'] as int,
currentGroup: json['currentGroup'] as int,
completedUses: (json['completedUses'] as List<dynamic>?)
?.map((e) => OneConstructUse.fromJson(e))
.whereType<OneConstructUse>()
.toList() ??
[],
finished: json['finished'] as bool? ?? false,
elapsedSeconds: json['elapsedSeconds'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'startedAt': startedAt.toIso8601String(),
'sortedConstructIds': sortedConstructIds.map((e) => e.toJson()).toList(),
'activityTypes': activityTypes.map((e) => e.name).toList(),
'userL1': userL1,
'userL2': userL2,
'currentIndex': currentIndex,
'currentGroup': currentGroup,
'completedUses': completedUses.map((e) => e.toJson()).toList(),
'finished': finished,
'elapsedSeconds': elapsedSeconds,
};
}
}

View file

@ -0,0 +1,102 @@
import 'dart:math';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeSessionRepo {
static final GetStorage _storage = GetStorage('vocab_practice_session');
static Future<VocabPracticeSessionModel> get currentSession async {
final cached = _getCached();
if (cached != null) {
return cached;
}
final r = Random();
final activityTypes = [
ActivityTypeEnum.lemmaMeaning,
//ActivityTypeEnum.lemmaAudio,
];
final types = List.generate(
VocabPracticeSessionModel.practiceGroupSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final targets = await _fetch();
final session = VocabPracticeSessionModel(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
sortedConstructIds: targets,
activityTypes: types,
completedUses: [],
);
await _setCached(session);
return session;
}
static Future<void> updateSession(
VocabPracticeSessionModel session,
) =>
_setCached(session);
static Future<VocabPracticeSessionModel> reloadSession() async {
_storage.erase();
return currentSession;
}
static Future<void> clearSession() => _storage.erase();
static Future<List<ConstructIdentifier>> _fetch() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// maintain a Map of ConstructIDs to last use dates and a sorted list of ConstructIDs
// based on last use. Update the map / list on practice completion
final Map<ConstructIdentifier, DateTime?> constructLastUseMap = {};
final List<ConstructIdentifier> sortedTargetIds = [];
for (final construct in constructs) {
constructLastUseMap[construct.id] = construct.lastUsed;
sortedTargetIds.add(construct.id);
}
sortedTargetIds.sort((a, b) {
final dateA = constructLastUseMap[a];
final dateB = constructLastUseMap[b];
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
return sortedTargetIds;
}
static VocabPracticeSessionModel? _getCached() {
final keys = List<String>.from(_storage.getKeys());
if (keys.isEmpty) return null;
try {
final json = _storage.read(keys.first) as Map<String, dynamic>;
return VocabPracticeSessionModel.fromJson(json);
} catch (e) {
_storage.remove(keys.first);
return null;
}
}
static Future<void> _setCached(VocabPracticeSessionModel session) async {
await _storage.erase();
await _storage.write(
session.startedAt.toIso8601String(),
session.toJson(),
);
}
}

View file

@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/audio_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class VocabPracticeView extends StatelessWidget {
final VocabPracticeState controller;
const VocabPracticeView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
spacing: 8.0,
children: [
Expanded(
child: AnimatedProgressBar(
height: 20.0,
widthPercent: controller.progress,
barColor: Theme.of(context).colorScheme.primary,
),
),
//keep track of state to update timer
ValueListenableBuilder(
valueListenable: controller.sessionLoader.state,
builder: (context, state, __) {
if (state is AsyncLoaded<VocabPracticeSessionModel>) {
return VocabTimerWidget(
key: ValueKey(state.value.startedAt),
initialSeconds: state.value.elapsedSeconds,
onTimeUpdate: controller.updateElapsedTime,
isRunning: !controller.isComplete,
);
}
return const SizedBox.shrink();
},
),
],
),
),
body: MaxWidthBody(
withScrolling: false,
padding: const EdgeInsets.all(0.0),
showBorder: false,
child: Expanded(
child: controller.isComplete
? CompletedActivitySessionView(controller)
: _OngoingActivitySessionView(controller),
),
),
);
}
}
class _OngoingActivitySessionView extends StatelessWidget {
final VocabPracticeState controller;
const _OngoingActivitySessionView(this.controller);
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.sessionLoader.state,
builder: (context, state, __) {
return switch (state) {
AsyncError<VocabPracticeSessionModel>(:final error) =>
ErrorIndicator(message: error.toString()),
AsyncLoaded<VocabPracticeSessionModel>(:final value) =>
value.currentConstructId != null &&
value.currentActivityType != null
? _VocabActivityView(
value.currentConstructId!,
value.currentActivityType!,
controller,
)
: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
),
_ => const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
),
};
},
);
}
}
class _VocabActivityView extends StatelessWidget {
final ConstructIdentifier constructId;
final ActivityTypeEnum activityType;
final VocabPracticeState controller;
const _VocabActivityView(
this.constructId,
this.activityType,
this.controller,
);
@override
Widget build(BuildContext context) {
return Column(
children: [
//per-activity instructions, add switch statement once there are more types
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.selectMeaning,
padding: EdgeInsets.symmetric(horizontal: 16.0),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
constructId.lemma,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
_ExampleMessageWidget(controller, constructId),
Flexible(
child: _ActivityChoicesWidget(
controller,
activityType,
constructId,
),
),
],
),
),
],
);
}
}
class _ExampleMessageWidget extends StatelessWidget {
final VocabPracticeState controller;
final ConstructIdentifier constructId;
const _ExampleMessageWidget(this.controller, this.constructId);
@override
Widget build(BuildContext context) {
return FutureBuilder<List<InlineSpan>?>(
future: controller.getExampleMessage(constructId),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox();
}
return Padding(
//styling like sent message bubble
padding: const EdgeInsets.all(16.0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: snapshot.data!,
),
),
),
);
},
);
}
}
class _ActivityChoicesWidget extends StatelessWidget {
final VocabPracticeState controller;
final ActivityTypeEnum activityType;
final ConstructIdentifier constructId;
const _ActivityChoicesWidget(
this.controller,
this.activityType,
this.constructId,
);
@override
Widget build(BuildContext context) {
if (controller.activityError != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//allow try to reload activity in case of error
ErrorIndicator(message: controller.activityError!),
const SizedBox(height: 16),
TextButton.icon(
onPressed: controller.loadActivity,
icon: const Icon(Icons.refresh),
label: Text(L10n.of(context).tryAgain),
),
],
);
}
final activity = controller.currentActivity;
if (controller.isLoadingActivity ||
activity == null ||
(activity.activityType == ActivityTypeEnum.lemmaMeaning &&
controller.isLoadingLemmaInfo)) {
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: const Center(
child: CircularProgressIndicator.adaptive(),
),
);
}
final choices = activity.multipleChoiceContent!.choices.toList();
return LayoutBuilder(
builder: (context, constraints) {
//Constrain max height to keep choices together on large screens, and allow shrinking to fit on smaller screens
final constrainedHeight = constraints.maxHeight.clamp(0.0, 400.0);
final cardHeight =
(constrainedHeight / (choices.length + 1)).clamp(50.0, 80.0);
return Container(
constraints: const BoxConstraints(maxHeight: 400.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: choices.map((choiceId) {
final bool isEnabled = !controller.isAwaitingNextActivity;
return _buildChoiceCard(
activity: activity,
choiceId: choiceId,
cardHeight: cardHeight,
isEnabled: isEnabled,
onPressed: () => controller.onSelectChoice(choiceId),
);
}).toList(),
),
),
);
},
);
}
Widget _buildChoiceCard({
required activity,
required String choiceId,
required double cardHeight,
required bool isEnabled,
required VoidCallback onPressed,
}) {
final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId);
switch (activity.activityType) {
case ActivityTypeEnum.lemmaMeaning:
return MeaningChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_meaning_$choiceId',
),
choiceId: choiceId,
displayText: controller.getChoiceText(choiceId),
emoji: controller.getChoiceEmoji(choiceId),
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
);
case ActivityTypeEnum.lemmaAudio:
return AudioChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_audio_$choiceId',
),
text: choiceId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
);
default:
return GameChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_basic_$choiceId',
),
shouldFlip: false,
transformId: choiceId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: isEnabled,
child: Text(controller.getChoiceText(choiceId)),
);
}
}
}

View file

@ -0,0 +1,91 @@
import 'dart:async';
import 'package:flutter/material.dart';
class VocabTimerWidget extends StatefulWidget {
final int initialSeconds;
final ValueChanged<int> onTimeUpdate;
final bool isRunning;
const VocabTimerWidget({
required this.initialSeconds,
required this.onTimeUpdate,
this.isRunning = true,
super.key,
});
@override
VocabTimerWidgetState createState() => VocabTimerWidgetState();
}
class VocabTimerWidgetState extends State<VocabTimerWidget> {
final Stopwatch _stopwatch = Stopwatch();
late int _initialSeconds;
Timer? _timer;
@override
void initState() {
super.initState();
_initialSeconds = widget.initialSeconds;
if (widget.isRunning) {
_startTimer();
}
}
@override
void didUpdateWidget(VocabTimerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isRunning && !widget.isRunning) {
_stopTimer();
} else if (!oldWidget.isRunning && widget.isRunning) {
_startTimer();
}
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
void _startTimer() {
_stopwatch.start();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
final currentSeconds = _getCurrentSeconds();
setState(() {});
widget.onTimeUpdate(currentSeconds);
});
}
void _stopTimer() {
_stopwatch.stop();
_timer?.cancel();
_timer = null;
}
int _getCurrentSeconds() {
return _initialSeconds + (_stopwatch.elapsedMilliseconds / 1000).round();
}
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.alarm, size: 20),
const SizedBox(width: 4.0),
Text(
_formatTime(_getCurrentSeconds()),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
);
}
}