From 44566e4374bc1956994ea6798729b782a4298cf7 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:03:05 -0500 Subject: [PATCH 01/17] chore: translation button style update translation appears in message bubble like in chat with a pressable button and sound effect --- .../analytics_practice_view.dart | 241 ++++++++---------- 1 file changed, 112 insertions(+), 129 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 36ebddc35..62acdd835 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; @@ -16,6 +14,7 @@ import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.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/common/widgets/pressable_button.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart'; @@ -24,6 +23,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; @@ -244,15 +244,12 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _ErrorBlankWidget( - activity: activity, - ), - const SizedBox(height: 12), - _GrammarErrorTranslationButton( key: ValueKey( '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', ), - translation: activity.translation, + activity: activity, ), + const SizedBox(height: 12), ], ), _ => const SizedBox(), @@ -307,58 +304,126 @@ class _ExampleMessageWidget extends StatelessWidget { } } -class _ErrorBlankWidget extends StatelessWidget { +class _ErrorBlankWidget extends StatefulWidget { final GrammarErrorPracticeActivityModel activity; const _ErrorBlankWidget({ + super.key, required this.activity, }); @override - Widget build(BuildContext context) { - final text = activity.text; - final errorOffset = activity.errorOffset; - final errorLength = activity.errorLength; + State<_ErrorBlankWidget> createState() => _ErrorBlankWidgetState(); +} - return 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, +class _ErrorBlankWidgetState extends State<_ErrorBlankWidget> { + late final String translation = widget.activity.translation; + bool _showTranslation = false; + + void _toggleTranslation() { + setState(() { + _showTranslation = !_showTranslation; + }); + } + + @override + Widget build(BuildContext context) { + final text = widget.activity.text; + final errorOffset = widget.activity.errorOffset; + final errorLength = widget.activity.errorLength; + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, ), - children: [ - if (errorOffset > 0) - TextSpan(text: text.characters.take(errorOffset).toString()), - WidgetSpan( - child: Container( - height: 4.0, - width: (errorLength * 8).toDouble(), - padding: const EdgeInsets.only(bottom: 2.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, + decoration: BoxDecoration( + color: Color.alphaBlend( + Colors.white.withAlpha(180), + ThemeData.dark().colorScheme.primary, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: + AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + children: [ + if (errorOffset > 0) + TextSpan( + text: text.characters.take(errorOffset).toString(), + ), + WidgetSpan( + child: Container( + height: 4.0, + width: (errorLength * 8).toDouble(), + padding: const EdgeInsets.only(bottom: 2.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + if (errorOffset + errorLength < text.length) + TextSpan( + text: text.characters + .skip(errorOffset + errorLength) + .toString(), + ), + ], ), ), - ), - if (errorOffset + errorLength < text.length) - TextSpan( - text: - text.characters.skip(errorOffset + errorLength).toString(), - ), - ], + const SizedBox(height: 8), + _showTranslation + ? Text( + translation, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: AppConfig.fontSizeFactor * + AppConfig.messageFontSize, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.left, + ) + : const SizedBox.shrink(), + ], + ), ), - ), + const SizedBox(height: 8), + PressableButton( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.primaryContainer, + depressed: _showTranslation, + onPressed: _toggleTranslation, + playSound: true, + colorFactor: 0.3, + builder: (context, depressed, shadowColor) => Stack( + alignment: Alignment.center, + children: [ + Container( + height: 40.0, + width: 40.0, + decoration: BoxDecoration( + color: depressed + ? shadowColor + : Theme.of(context).colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + ), + const Icon( + Icons.translate, + size: 20, + ), + ], + ), + ), + ], ); } } @@ -553,85 +618,3 @@ class _ChoiceCard extends StatelessWidget { } } } - -class _GrammarErrorTranslationButton extends StatefulWidget { - final String translation; - - const _GrammarErrorTranslationButton({ - super.key, - required this.translation, - }); - - @override - State<_GrammarErrorTranslationButton> createState() => - _GrammarErrorTranslationButtonState(); -} - -class _GrammarErrorTranslationButtonState - extends State<_GrammarErrorTranslationButton> { - bool _showTranslation = false; - - void _toggleTranslation() { - if (_showTranslation) { - setState(() { - _showTranslation = false; - }); - } else { - setState(() { - _showTranslation = true; - }); - } - } - - @override - Widget build(BuildContext context) { - return Center( - child: GestureDetector( - onTap: _toggleTranslation, - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - if (_showTranslation) - Flexible( - 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: Text( - widget.translation, - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, - fontSize: - AppConfig.fontSizeFactor * AppConfig.messageFontSize, - ), - textAlign: TextAlign.center, - ), - ), - ), - if (!_showTranslation) - ElevatedButton( - onPressed: _toggleTranslation, - style: ElevatedButton.styleFrom( - shape: const CircleBorder(), - padding: const EdgeInsets.all(8), - ), - child: const Icon( - Icons.lightbulb_outline, - size: 20, - ), - ), - ], - ), - ), - ); - } -} From 5f540e9d16e83475c593340f39ac2856ddea3aa8 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:30:16 -0500 Subject: [PATCH 02/17] chore: ensure grammar category has example and multiple choices --- .../analytics_practice_page.dart | 3 +- .../analytics_practice_session_repo.dart | 50 ++++++++++++++++--- .../analytics_practice_view.dart | 3 +- .../practice_activities/practice_target.dart | 5 ++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 44b471f92..4f88cd84a 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -535,8 +535,7 @@ class AnalyticsPracticeState extends State String? form; if (widget.type == ConstructTypeEnum.morph) { - if (target.morphFeature == null) return null; - form = token.lemma.form; + return target.exampleMessage; } return ExampleMessageUtil.getExampleMessage( diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index c2c507741..9117c0d16 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -4,6 +4,7 @@ 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/common/network/requests.dart'; @@ -63,15 +64,16 @@ class AnalyticsPracticeSessionRepo { final remainingCount = (AnalyticsPracticeConstants.practiceGroupSize + AnalyticsPracticeConstants.errorBufferSize) - targets.length; - final morphEntries = morphs.entries.take(remainingCount); + final morphEntries = morphs.take(remainingCount); for (final entry in morphEntries) { targets.add( AnalyticsActivityTarget( target: PracticeTarget( - tokens: [entry.key], + tokens: [entry.token], activityType: ActivityTypeEnum.grammarCategory, - morphFeature: entry.value, + morphFeature: entry.feature, + exampleMessage: entry.exampleMessage, ), ), ); @@ -125,7 +127,7 @@ class AnalyticsPracticeSessionRepo { return targets; } - static Future> _fetchMorphs() async { + static Future> _fetchMorphs() async { final constructs = await MatrixState .pangeaController.matrixState.analyticsDataService .getAggregatedConstructs(ConstructTypeEnum.morph) @@ -141,7 +143,7 @@ class AnalyticsPracticeSessionRepo { return dateA.compareTo(dateB); }); - final targets = {}; + final targets = []; final Set seenForms = {}; for (final entry in constructs) { @@ -152,7 +154,12 @@ class AnalyticsPracticeSessionRepo { } final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); - if (feature == MorphFeaturesEnum.Unknown) { + List? exampleMessage; + // Skip single option features + if (feature == MorphFeaturesEnum.Unknown || + feature == MorphFeaturesEnum.Poss || + feature == MorphFeaturesEnum.Reflex || + feature == MorphFeaturesEnum.PrepCase) { continue; } @@ -169,6 +176,17 @@ class AnalyticsPracticeSessionRepo { continue; } + exampleMessage = await ExampleMessageUtil.getExampleMessage( + await MatrixState.pangeaController.matrixState.analyticsDataService + .getConstructUse(entry.id), + MatrixState.pangeaController.matrixState.client, + form: form, + ); + + if (exampleMessage == null) { + continue; + } + seenForms.add(form); final token = PangeaToken( lemma: Lemma( @@ -180,7 +198,13 @@ class AnalyticsPracticeSessionRepo { pos: 'other', morph: {feature: use.lemma}, ); - targets[token] = feature; + targets.add( + MorphPracticeTarget( + feature: feature, + token: token, + exampleMessage: exampleMessage, + ), + ); break; } } @@ -312,3 +336,15 @@ class AnalyticsPracticeSessionRepo { return targets; } } + +class MorphPracticeTarget { + final PangeaToken token; + final MorphFeaturesEnum feature; + final List? exampleMessage; + + MorphPracticeTarget({ + required this.token, + required this.feature, + this.exampleMessage, + }); +} diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 62acdd835..2724a191b 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; @@ -23,7 +25,6 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 87c1ce7e7..93778c1b4 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -24,10 +25,14 @@ class PracticeTarget { /// this is only defined for morphId activities final MorphFeaturesEnum? morphFeature; + /// Generated example message for the target, if applicable + final List? exampleMessage; + PracticeTarget({ required this.tokens, required this.activityType, this.morphFeature, + this.exampleMessage, }) { if (ActivityTypeEnum.morphId == activityType && morphFeature == null) { throw Exception("morphFeature must be defined for morphId activities"); From f486269ee14f7c8829584e03151a971bd44393bc Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 28 Jan 2026 15:39:38 -0500 Subject: [PATCH 03/17] chore: don't give normalization errors or single choices --- .../analytics_practice_session_repo.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 9117c0d16..c12b82d3c 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -159,7 +159,8 @@ class AnalyticsPracticeSessionRepo { if (feature == MorphFeaturesEnum.Unknown || feature == MorphFeaturesEnum.Poss || feature == MorphFeaturesEnum.Reflex || - feature == MorphFeaturesEnum.PrepCase) { + feature == MorphFeaturesEnum.PrepCase || + feature == MorphFeaturesEnum.NumType) { continue; } @@ -281,7 +282,11 @@ class AnalyticsPracticeSessionRepo { if (igcMatch!.match.offset == 0 && igcMatch.match.length >= stepText.trim().characters.length) { - // Skip if the grammar error spans the entire step + continue; + } + + if (igcMatch.match.isNormalizationError()) { + // Skip normalization errors continue; } @@ -296,9 +301,10 @@ class AnalyticsPracticeSessionRepo { ) .toList(); - // Skip if no valid tokens found for this grammar error - if (choiceTokens.isEmpty) continue; - + // Skip if no valid tokens found for this grammar error, or only one answer + if (choiceTokens.length <= 1) { + continue; + } String? translation; try { translation = await event.requestRespresentationByL1(); From fbb43fee6e7a49ae7bfb6b8dcac4da88b023d32c Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:14:18 -0500 Subject: [PATCH 04/17] chore: skip recently practiced grammar errors wip: only partially works due to analytics not being given to every question --- .../analytics_practice_session_repo.dart | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index c12b82d3c..65c9cd7e0 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -160,7 +160,8 @@ class AnalyticsPracticeSessionRepo { feature == MorphFeaturesEnum.Poss || feature == MorphFeaturesEnum.Reflex || feature == MorphFeaturesEnum.PrepCase || - feature == MorphFeaturesEnum.NumType) { + feature == MorphFeaturesEnum.NumType || + feature == MorphFeaturesEnum.NumForm) { continue; } @@ -305,6 +306,29 @@ class AnalyticsPracticeSessionRepo { if (choiceTokens.length <= 1) { continue; } + + // Check if the first token was practiced in the last 24 hours + final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); + final firstToken = choiceTokens.first; + final tokenIdentifier = ConstructIdentifier( + lemma: firstToken.lemma.text, + type: ConstructTypeEnum.vocab, + category: firstToken.pos, + ); + + final recentUses = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getUses(since: cutoffTime); + + final hasRecentPractice = recentUses.any( + (use) => + use.identifier == tokenIdentifier && + (use.useType == ConstructUseTypeEnum.corGE || + use.useType == ConstructUseTypeEnum.incGE), + ); + + if (hasRecentPractice) continue; + String? translation; try { translation = await event.requestRespresentationByL1(); @@ -321,6 +345,7 @@ class AnalyticsPracticeSessionRepo { } if (translation == null) continue; + targets.add( AnalyticsActivityTarget( target: PracticeTarget( From 1f47aecb13b01c8cb7092b072d4e6e93ee670a07 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:13:14 -0500 Subject: [PATCH 05/17] fix: return capped uses allows all grammar error targets to be searched for recent uses and filtered out, even maxed out ones --- lib/pangea/analytics_data/analytics_data_service.dart | 4 +++- .../analytics_practice/analytics_practice_session_repo.dart | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index b339c8638..1cfe805be 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -241,6 +241,7 @@ class AnalyticsDataService { String? roomId, DateTime? since, ConstructUseTypeEnum? type, + bool filterCapped = true, }) async { await _ensureInitialized(); final uses = await _analyticsClientGetter.database.getUses( @@ -263,7 +264,8 @@ class AnalyticsDataService { cappedLastUseCache[use.identifier] = constructs.cappedLastUse; } final cappedLastUse = cappedLastUseCache[use.identifier]; - if (cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse)) { + if (filterCapped && + (cappedLastUse != null && use.timeStamp.isAfter(cappedLastUse))) { continue; } filtered.add(use); diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 65c9cd7e0..9c430bcb0 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -318,7 +318,7 @@ class AnalyticsPracticeSessionRepo { final recentUses = await MatrixState .pangeaController.matrixState.analyticsDataService - .getUses(since: cutoffTime); + .getUses(since: cutoffTime, filterCapped: false); final hasRecentPractice = recentUses.any( (use) => From 0cb3d472c7a11acf7cbc42f9e98f1386752faaa2 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:17:55 -0500 Subject: [PATCH 06/17] chore: show correct answer hint button and don't show answer description on selection of correct answer --- .../analytics_practice_page.dart | 10 + .../analytics_practice_view.dart | 206 +++++++++++++----- 2 files changed, 166 insertions(+), 50 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 4f88cd84a..3988184ae 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -101,6 +101,8 @@ class AnalyticsPracticeState extends State final ValueNotifier selectedMorphChoice = ValueNotifier(null); + final ValueNotifier hintPressedNotifier = ValueNotifier(false); + final Map> _choiceTexts = {}; final Map> _choiceEmojis = {}; @@ -125,6 +127,7 @@ class AnalyticsPracticeState extends State progressNotifier.dispose(); enableChoicesNotifier.dispose(); selectedMorphChoice.dispose(); + hintPressedNotifier.dispose(); super.dispose(); } @@ -210,6 +213,7 @@ class AnalyticsPracticeState extends State activityState.value = const AsyncState.loading(); activityTarget.value = null; selectedMorphChoice.value = null; + hintPressedNotifier.value = false; enableChoicesNotifier.value = true; progressNotifier.value = 0.0; _queue.clear(); @@ -282,6 +286,7 @@ class AnalyticsPracticeState extends State try { activityState.value = const AsyncState.loading(); selectedMorphChoice.value = null; + hintPressedNotifier.value = false; final req = activityTarget.value!; final res = await _fetchActivity(req); @@ -324,6 +329,7 @@ class AnalyticsPracticeState extends State while (_queue.isNotEmpty) { activityState.value = const AsyncState.loading(); selectedMorphChoice.value = null; + hintPressedNotifier.value = false; final nextActivityCompleter = _queue.removeFirst(); try { @@ -477,6 +483,10 @@ class AnalyticsPracticeState extends State await _analyticsService.updateService.addAnalytics(null, [use]); } + void onHintPressed() { + hintPressedNotifier.value = !hintPressedNotifier.value; + } + Future onSelectChoice( String choiceContent, ) async { diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 2fd5c9af5..5e5b31f76 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; @@ -23,7 +25,6 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; @@ -162,28 +163,7 @@ class _AnalyticsActivityView extends StatelessWidget { const SizedBox(height: 16.0), _ActivityChoicesWidget(controller), const SizedBox(height: 16.0), - ListenableBuilder( - listenable: Listenable.merge([ - controller.activityState, - controller.selectedMorphChoice, - ]), - builder: (context, _) { - final activityState = controller.activityState.value; - final selectedChoice = controller.selectedMorphChoice.value; - - if (activityState - is! AsyncLoaded || - selectedChoice == null) { - return const SizedBox.shrink(); - } - - return MorphMeaningWidget( - feature: selectedChoice.feature, - tag: selectedChoice.tag, - blankErrorFeedback: true, - ); - }, - ), + _WrongAnswerFeedback(controller: controller), ], ); } @@ -228,6 +208,26 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { ), ), ), + ActivityTypeEnum.grammarCategory => Center( + child: Column( + children: [ + _CorrectAnswerHint(controller: controller), + _ExampleMessageWidget( + controller.getExampleMessage(target!.target), + ), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: controller.hintPressedNotifier, + builder: (context, hintPressed, __) { + return HintButton( + depressed: hintPressed, + onPressed: controller.onHintPressed, + ); + }, + ), + ], + ), + ), _ => SizedBox( height: 100.0, child: Center( @@ -282,6 +282,96 @@ class _ExampleMessageWidget extends StatelessWidget { } } +class _CorrectAnswerHint extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _CorrectAnswerHint({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.hintPressedNotifier, + builder: (context, hintPressed, __) { + if (!hintPressed) { + return const SizedBox.shrink(); + } + + return ValueListenableBuilder( + valueListenable: controller.activityState, + builder: (context, state, __) { + if (state is! AsyncLoaded) { + return const SizedBox.shrink(); + } + + final activity = state.value; + if (activity is! MorphPracticeActivityModel) { + return const SizedBox.shrink(); + } + + final correctAnswerTag = + activity.multipleChoiceContent.answers.first; + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: MorphMeaningWidget( + feature: activity.morphFeature, + tag: correctAnswerTag, + ), + ); + }, + ); + }, + ); + } +} + +class _WrongAnswerFeedback extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _WrongAnswerFeedback({ + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([ + controller.activityState, + controller.selectedMorphChoice, + ]), + builder: (context, _) { + final activityState = controller.activityState.value; + final selectedChoice = controller.selectedMorphChoice.value; + + if (activityState + is! AsyncLoaded || + selectedChoice == null) { + return const SizedBox.shrink(); + } + + final activity = activityState.value; + final isWrongAnswer = + !activity.multipleChoiceContent.isCorrect(selectedChoice.tag); + + if (!isWrongAnswer) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: MorphMeaningWidget( + feature: selectedChoice.feature, + tag: selectedChoice.tag, + blankErrorFeedback: true, + ), + ); + }, + ); + } +} + class _ErrorBlankWidget extends StatefulWidget { final GrammarErrorPracticeActivityModel activity; @@ -413,38 +503,54 @@ class _ErrorBlankWidgetState extends State<_ErrorBlankWidget> { ), ), const SizedBox(height: 8), - PressableButton( - borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.primaryContainer, - depressed: _showTranslation, - onPressed: _toggleTranslation, - playSound: true, - colorFactor: 0.3, - builder: (context, depressed, shadowColor) => Stack( - alignment: Alignment.center, - children: [ - Container( - height: 40.0, - width: 40.0, - decoration: BoxDecoration( - color: depressed - ? shadowColor - : Theme.of(context).colorScheme.primaryContainer, - shape: BoxShape.circle, - ), - ), - const Icon( - Icons.translate, - size: 20, - ), - ], - ), - ), + HintButton(depressed: _showTranslation, onPressed: _toggleTranslation), ], ); } } +class HintButton extends StatelessWidget { + final VoidCallback onPressed; + final bool depressed; + + const HintButton({ + required this.onPressed, + required this.depressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + return PressableButton( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.primaryContainer, + onPressed: onPressed, + depressed: depressed, + playSound: true, + colorFactor: 0.3, + builder: (context, depressed, shadowColor) => Stack( + alignment: Alignment.center, + children: [ + Container( + height: 40.0, + width: 40.0, + decoration: BoxDecoration( + color: depressed + ? shadowColor + : Theme.of(context).colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + ), + const Icon( + Icons.lightbulb_outline, + size: 20, + ), + ], + ), + ); + } +} + class _ActivityChoicesWidget extends StatelessWidget { final AnalyticsPracticeState controller; From a84ded27af19931cd9bfaffd3f88fa077a7bea62 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:48:25 -0500 Subject: [PATCH 07/17] make grammar icons larger and more spaced --- .../analytics_practice/choice_cards/grammar_choice_card.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart index 7ff543247..230944160 100644 --- a/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart @@ -35,7 +35,7 @@ class GrammarChoiceCard extends StatelessWidget { final baseTextSize = (Theme.of(context).textTheme.titleMedium?.fontSize ?? 16) * (height / 72.0).clamp(1.0, 1.4); - final emojiSize = baseTextSize * 1.2; + final emojiSize = baseTextSize * 1.5; final copy = getGrammarCopy( category: feature.name, lemma: tag, @@ -54,7 +54,7 @@ class GrammarChoiceCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( - width: height * .7, + width: height, height: height, child: Center( child: MorphIcon( From 3cf69183941d022b7dea9adcde013faeee7fea75 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:41:46 -0500 Subject: [PATCH 08/17] fix: add exampleMessage to AnalyticsActivityTarget and remove it from PracticeTarget --- .../analytics_practice/analytics_practice_page.dart | 8 +++----- .../analytics_practice_session_model.dart | 13 +++++++++++++ .../analytics_practice_session_repo.dart | 6 ++++-- .../analytics_practice/analytics_practice_view.dart | 4 ++-- .../message_activity_request.dart | 3 +++ lib/pangea/practice_activities/practice_target.dart | 5 ----- 6 files changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 3988184ae..1bcc52d01 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -23,7 +23,6 @@ import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; @@ -538,20 +537,19 @@ class AnalyticsPracticeState extends State } Future?> getExampleMessage( - PracticeTarget target, + MessageActivityRequest activityRequest, ) async { + final target = activityRequest.target; final token = target.tokens.first; final construct = target.targetTokenConstructID(token); - String? form; if (widget.type == ConstructTypeEnum.morph) { - return target.exampleMessage; + return activityRequest.morphExampleInfo?.exampleMessage; } return ExampleMessageUtil.getExampleMessage( await _analyticsService.getConstructUse(construct), Matrix.of(context).client, - form: form, ); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index 9ab395a13..24020a8f1 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -1,16 +1,28 @@ +import 'package:flutter/painting.dart'; + import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +class MorphExampleInfo { + final List exampleMessage; + + const MorphExampleInfo({ + required this.exampleMessage, + }); +} + class AnalyticsActivityTarget { final PracticeTarget target; final GrammarErrorRequestInfo? grammarErrorInfo; + final MorphExampleInfo? morphExampleInfo; AnalyticsActivityTarget({ required this.target, this.grammarErrorInfo, + this.morphExampleInfo, }); Map toJson() => { @@ -79,6 +91,7 @@ class AnalyticsPracticeSessionModel { activityQualityFeedback: null, target: target.target, grammarErrorInfo: target.grammarErrorInfo, + morphExampleInfo: target.morphExampleInfo, ); }).toList(); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 9c430bcb0..9172acf04 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -73,6 +73,8 @@ class AnalyticsPracticeSessionRepo { tokens: [entry.token], activityType: ActivityTypeEnum.grammarCategory, morphFeature: entry.feature, + ), + morphExampleInfo: MorphExampleInfo( exampleMessage: entry.exampleMessage, ), ), @@ -371,11 +373,11 @@ class AnalyticsPracticeSessionRepo { class MorphPracticeTarget { final PangeaToken token; final MorphFeaturesEnum feature; - final List? exampleMessage; + final List exampleMessage; MorphPracticeTarget({ required this.token, required this.feature, - this.exampleMessage, + required this.exampleMessage, }); } diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 5e5b31f76..7dc768784 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -213,7 +213,7 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { children: [ _CorrectAnswerHint(controller: controller), _ExampleMessageWidget( - controller.getExampleMessage(target!.target), + controller.getExampleMessage(target!), ), const SizedBox(height: 12), ValueListenableBuilder( @@ -232,7 +232,7 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { height: 100.0, child: Center( child: _ExampleMessageWidget( - controller.getExampleMessage(target!.target), + controller.getExampleMessage(target!), ), ), ), diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 7452dd026..ec6a9e491 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; @@ -79,6 +80,7 @@ class MessageActivityRequest { final PracticeTarget target; final ActivityQualityFeedback? activityQualityFeedback; final GrammarErrorRequestInfo? grammarErrorInfo; + final MorphExampleInfo? morphExampleInfo; MessageActivityRequest({ required this.userL1, @@ -86,6 +88,7 @@ class MessageActivityRequest { required this.activityQualityFeedback, required this.target, this.grammarErrorInfo, + this.morphExampleInfo, }) { if (target.tokens.isEmpty) { throw Exception('Target tokens must not be empty'); diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 93778c1b4..87c1ce7e7 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -1,5 +1,4 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -25,14 +24,10 @@ class PracticeTarget { /// this is only defined for morphId activities final MorphFeaturesEnum? morphFeature; - /// Generated example message for the target, if applicable - final List? exampleMessage; - PracticeTarget({ required this.tokens, required this.activityType, this.morphFeature, - this.exampleMessage, }) { if (ActivityTypeEnum.morphId == activityType && morphFeature == null) { throw Exception("morphFeature must be defined for morphId activities"); From eba8435fdd62842b6519ab339e975683bccf42a1 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:10:10 -0500 Subject: [PATCH 09/17] fix: only call getUses once in fetchErrors --- .../analytics_practice_session_repo.dart | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 9172acf04..500f7f3f4 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -217,14 +217,32 @@ class AnalyticsPracticeSessionRepo { } static Future> _fetchErrors() async { - final uses = await MatrixState + // Fetch all recent uses in one call (not filtering blocked constructs) + final allRecentUses = await MatrixState .pangeaController.matrixState.analyticsDataService - .getUses(count: 100, type: ConstructUseTypeEnum.ga); + .getUses(count: 200, filterCapped: false); + + // Filter for grammar error uses + final grammarErrorUses = allRecentUses + .where((use) => use.useType == ConstructUseTypeEnum.ga) + .toList(); + + // Create list of recently used constructs + final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); + final recentlyPracticedConstructs = allRecentUses + .where( + (use) => + use.metadata.timeStamp.isAfter(cutoffTime) && + (use.useType == ConstructUseTypeEnum.corGE || + use.useType == ConstructUseTypeEnum.incGE), + ) + .map((use) => use.identifier) + .toSet(); final client = MatrixState.pangeaController.matrixState.client; final Map idsToEvents = {}; - for (final use in uses) { + for (final use in grammarErrorUses) { final eventID = use.metadata.eventId; if (eventID == null || idsToEvents.containsKey(eventID)) continue; @@ -309,8 +327,6 @@ class AnalyticsPracticeSessionRepo { continue; } - // Check if the first token was practiced in the last 24 hours - final cutoffTime = DateTime.now().subtract(const Duration(hours: 24)); final firstToken = choiceTokens.first; final tokenIdentifier = ConstructIdentifier( lemma: firstToken.lemma.text, @@ -318,16 +334,8 @@ class AnalyticsPracticeSessionRepo { category: firstToken.pos, ); - final recentUses = await MatrixState - .pangeaController.matrixState.analyticsDataService - .getUses(since: cutoffTime, filterCapped: false); - - final hasRecentPractice = recentUses.any( - (use) => - use.identifier == tokenIdentifier && - (use.useType == ConstructUseTypeEnum.corGE || - use.useType == ConstructUseTypeEnum.incGE), - ); + final hasRecentPractice = + recentlyPracticedConstructs.contains(tokenIdentifier); if (hasRecentPractice) continue; From 10197ae209ff2d75e253c73fe3819e6b23184562 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:23:09 -0500 Subject: [PATCH 10/17] fix: use MorphInfoRepo to filter valid morph categories --- .../analytics_practice_session_repo.dart | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 500f7f3f4..5e82d6b6d 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -13,8 +13,11 @@ 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'; @@ -135,6 +138,31 @@ class AnalyticsPracticeSessionRepo { .getAggregatedConstructs(ConstructTypeEnum.morph) .then((map) => map.values.toList()); + final morphInfoRequest = MorphInfoRequest( + userL1: MatrixState.pangeaController.userController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + userL2: MatrixState.pangeaController.userController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + ); + + final morphInfoResult = await MorphInfoRepo.get( + MatrixState.pangeaController.userController.accessToken, + morphInfoRequest, + ); + + // Build list of features with multiple tags (valid for practice) + final List validFeatures = []; + if (!morphInfoResult.isError) { + final response = morphInfoResult.asValue?.value; + if (response != null) { + for (final feature in response.features) { + if (feature.tags.length > 1) { + validFeatures.add(feature.code); + } + } + } + } + // sort by last used descending, nulls first constructs.sort((a, b) { final dateA = a.lastUsed; @@ -156,17 +184,14 @@ class AnalyticsPracticeSessionRepo { } final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); - List? exampleMessage; - // Skip single option features + + // Only include features that are in the valid list (have multiple tags) if (feature == MorphFeaturesEnum.Unknown || - feature == MorphFeaturesEnum.Poss || - feature == MorphFeaturesEnum.Reflex || - feature == MorphFeaturesEnum.PrepCase || - feature == MorphFeaturesEnum.NumType || - feature == MorphFeaturesEnum.NumForm) { + (validFeatures.isNotEmpty && !validFeatures.contains(feature.name))) { continue; } + List? exampleMessage; for (final use in entry.cappedUses) { if (targets.length >= (AnalyticsPracticeConstants.practiceGroupSize + From d1c6effb2acf927b1c61e2eae8c3467f4fe94df0 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:33:32 -0500 Subject: [PATCH 11/17] chore: add morphExampleInfo to activity model --- .../analytics_practice_session_model.dart | 40 +++++++++++++++++++ .../morph_category_activity_generator.dart | 3 ++ .../practice_activity_model.dart | 12 ++++++ 3 files changed, 55 insertions(+) diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index 24020a8f1..ebeccdce0 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -12,6 +12,42 @@ class MorphExampleInfo { const MorphExampleInfo({ required this.exampleMessage, }); + + Map toJson() { + final segments = >[]; + + for (final span in exampleMessage) { + if (span is TextSpan) { + segments.add({ + 'text': span.text ?? '', + 'isBold': span.style?.fontWeight == FontWeight.bold, + }); + } + } + + return { + 'segments': segments, + }; + } + + factory MorphExampleInfo.fromJson(Map json) { + final segments = json['segments'] as List? ?? []; + + final spans = []; + for (final segment in segments) { + final text = segment['text'] as String? ?? ''; + final isBold = segment['isBold'] as bool? ?? false; + + spans.add( + TextSpan( + text: text, + style: isBold ? const TextStyle(fontWeight: FontWeight.bold) : null, + ), + ); + } + + return MorphExampleInfo(exampleMessage: spans); + } } class AnalyticsActivityTarget { @@ -28,6 +64,7 @@ class AnalyticsActivityTarget { Map toJson() => { 'target': target.toJson(), 'grammarErrorInfo': grammarErrorInfo?.toJson(), + 'morphExampleInfo': morphExampleInfo?.toJson(), }; factory AnalyticsActivityTarget.fromJson(Map json) => @@ -36,6 +73,9 @@ class AnalyticsActivityTarget { grammarErrorInfo: json['grammarErrorInfo'] != null ? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo']) : null, + morphExampleInfo: json['morphExampleInfo'] != null + ? MorphExampleInfo.fromJson(json['morphExampleInfo']) + : null, ); } diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart index 545e466ba..78ef8ba78 100644 --- a/lib/pangea/analytics_practice/morph_category_activity_generator.dart +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; import 'package:fluffychat/pangea/morphs/morph_models.dart'; @@ -58,6 +59,8 @@ class MorphCategoryActivityGenerator { choices: choices.toSet(), answers: {morphTag}, ), + morphExampleInfo: + req.morphExampleInfo ?? const MorphExampleInfo(exampleMessage: []), ), ); } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index c0531dcba..4e16f1099 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -3,6 +3,7 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; @@ -111,6 +112,9 @@ sealed class PracticeActivityModel { tokens: tokens, morphFeature: morph!, multipleChoiceContent: multipleChoiceContent!, + morphExampleInfo: json['morph_example_info'] != null + ? MorphExampleInfo.fromJson(json['morph_example_info']) + : const MorphExampleInfo(exampleMessage: []), ); case ActivityTypeEnum.lemmaAudio: assert( @@ -307,6 +311,7 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { required super.langCode, required super.morphFeature, required super.multipleChoiceContent, + required this.morphExampleInfo, }); @override @@ -330,6 +335,13 @@ class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { xp: useType.pointValue, ); } + + @override + Map toJson() { + final json = super.toJson(); + json['morph_example_info'] = morphExampleInfo.toJson(); + return json; + } } class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel { From 92aff09a697c58ccd7e451f819a06074425bdd2f Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:34:15 -0500 Subject: [PATCH 12/17] fix: missing line --- lib/pangea/practice_activities/practice_activity_model.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 4e16f1099..eddbdacf1 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -306,6 +306,7 @@ sealed class MorphPracticeActivityModel } class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel { + final MorphExampleInfo morphExampleInfo; MorphCategoryPracticeActivityModel({ required super.tokens, required super.langCode, From 93c8393ab7cbb3f3d1696676a8e8910f25673e65 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 3 Feb 2026 09:16:38 -0500 Subject: [PATCH 13/17] fix: update data type of user genders in bot options model --- .../models/bot_options_model.dart | 38 +++++++++++++------ .../utils/bot_client_extension.dart | 12 +++++- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/lib/pangea/chat_settings/models/bot_options_model.dart b/lib/pangea/chat_settings/models/bot_options_model.dart index d8670dbab..8800212b7 100644 --- a/lib/pangea/chat_settings/models/bot_options_model.dart +++ b/lib/pangea/chat_settings/models/bot_options_model.dart @@ -24,7 +24,7 @@ class BotOptionsModel { final String? textAdventureGameMasterInstructions; final String? targetLanguage; final String? targetVoice; - final GenderEnum targetGender; + final Map userGenders; const BotOptionsModel({ //////////////////////////////////////////////////////////////////////////// @@ -37,7 +37,7 @@ class BotOptionsModel { this.mode = BotMode.discussion, this.targetLanguage, this.targetVoice, - this.targetGender = GenderEnum.unselected, + this.userGenders = const {}, //////////////////////////////////////////////////////////////////////////// // Discussion Mode Options @@ -61,6 +61,22 @@ class BotOptionsModel { }); factory BotOptionsModel.fromJson(json) { + final genderEntry = json[ModelKey.targetGender]; + Map targetGenders = {}; + if (genderEntry is Map) { + targetGenders = Map.fromEntries( + genderEntry.entries.map( + (e) => MapEntry( + e.key, + GenderEnum.values.firstWhere( + (g) => g.name == e.value, + orElse: () => GenderEnum.unselected, + ), + ), + ), + ); + } + return BotOptionsModel( ////////////////////////////////////////////////////////////////////////// // General Bot Options @@ -76,12 +92,7 @@ class BotOptionsModel { mode: json[ModelKey.mode] ?? BotMode.discussion, targetLanguage: json[ModelKey.targetLanguage], targetVoice: json[ModelKey.targetVoice], - targetGender: json[ModelKey.targetGender] != null - ? GenderEnum.values.firstWhere( - (g) => g.name == json[ModelKey.targetGender], - orElse: () => GenderEnum.unselected, - ) - : GenderEnum.unselected, + userGenders: targetGenders, ////////////////////////////////////////////////////////////////////////// // Discussion Mode Options @@ -112,6 +123,11 @@ class BotOptionsModel { Map toJson() { final data = {}; try { + final Map gendersEntry = {}; + for (final entry in userGenders.entries) { + gendersEntry[entry.key] = entry.value.name; + } + // data[ModelKey.isConversationBotChat] = isConversationBotChat; data[ModelKey.languageLevel] = languageLevel.storageInt; data[ModelKey.safetyModeration] = safetyModeration; @@ -130,7 +146,7 @@ class BotOptionsModel { data[ModelKey.customTriggerReactionKey] = customTriggerReactionKey ?? "⏩"; data[ModelKey.textAdventureGameMasterInstructions] = textAdventureGameMasterInstructions; - data[ModelKey.targetGender] = targetGender.name; + data[ModelKey.targetGender] = gendersEntry; return data; } catch (e, s) { debugger(when: kDebugMode); @@ -159,7 +175,7 @@ class BotOptionsModel { String? textAdventureGameMasterInstructions, String? targetLanguage, String? targetVoice, - GenderEnum? targetGender, + Map? userGenders, }) { return BotOptionsModel( languageLevel: languageLevel ?? this.languageLevel, @@ -183,7 +199,7 @@ class BotOptionsModel { this.textAdventureGameMasterInstructions, targetLanguage: targetLanguage ?? this.targetLanguage, targetVoice: targetVoice ?? this.targetVoice, - targetGender: targetGender ?? this.targetGender, + userGenders: userGenders ?? this.userGenders, ); } } diff --git a/lib/pangea/chat_settings/utils/bot_client_extension.dart b/lib/pangea/chat_settings/utils/bot_client_extension.dart index 6ae38cb56..c1ad861b7 100644 --- a/lib/pangea/chat_settings/utils/bot_client_extension.dart +++ b/lib/pangea/chat_settings/utils/bot_client_extension.dart @@ -9,6 +9,7 @@ import 'package:fluffychat/pangea/chat_settings/constants/bot_mode.dart'; import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/learning_settings/gender_enum.dart'; import 'package:fluffychat/pangea/user/user_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -64,15 +65,22 @@ extension BotClientExtension on Client { if (botOptions.targetLanguage == targetLanguage && botOptions.languageLevel == languageLevel && botOptions.targetVoice == voice && - botOptions.targetGender == gender) { + botOptions.userGenders[userID] == gender) { continue; } + final updatedGenders = + Map.from(botOptions.userGenders); + + if (updatedGenders[userID] != gender) { + updatedGenders[userID!] = gender; + } + final updated = botOptions.copyWith( targetLanguage: targetLanguage, languageLevel: languageLevel, targetVoice: voice, - targetGender: gender, + userGenders: updatedGenders, ); futures.add(targetBotRoom.setBotOptions(updated)); } From d99936dc62a503b67c7e4e5044dc5662792b9be0 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 3 Feb 2026 09:40:43 -0500 Subject: [PATCH 14/17] fix: move use activity image background setting into pangea user-specific style settings --- lib/config/setting_keys.dart | 2 - lib/pages/settings_chat/settings_chat.dart | 12 ++++ .../settings_chat/settings_chat_view.dart | 10 ++- .../common/controllers/pangea_controller.dart | 6 +- lib/pangea/user/style_settings_repo.dart | 70 +++++++++++++++---- lib/widgets/matrix.dart | 6 +- 6 files changed, 80 insertions(+), 26 deletions(-) diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 9d938e0af..c37e79f13 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -35,8 +35,6 @@ abstract class SettingKeys { static const String displayNavigationRail = 'chat.fluffy.display_navigation_rail'; // #Pangea - static const String useActivityImageAsChatBackground = - 'pangea.use_activity_image_as_chat_background'; static const String volume = 'pangea.volume'; static const String showedActivityMenu = 'pangea.showed_activity_menu_tutorial'; diff --git a/lib/pages/settings_chat/settings_chat.dart b/lib/pages/settings_chat/settings_chat.dart index 1c1035559..3d30663ac 100644 --- a/lib/pages/settings_chat/settings_chat.dart +++ b/lib/pages/settings_chat/settings_chat.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/user/style_settings_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'settings_chat_view.dart'; class SettingsChat extends StatefulWidget { @@ -10,6 +13,15 @@ class SettingsChat extends StatefulWidget { } class SettingsChatController extends State { + // #Pangea + Future setUseActivityImageBackground(bool value) async { + final userId = Matrix.of(context).client.userID!; + AppConfig.useActivityImageAsChatBackground = value; + setState(() {}); + await StyleSettingsRepo.setUseActivityImageBackground(userId, value); + } + // Pangea# + @override Widget build(BuildContext context) => SettingsChatView(this); } diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index 64341abba..16db97abc 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -80,12 +80,10 @@ class SettingsChatView extends StatelessWidget { ), // #Pangea - SettingsSwitchListTile.adaptive( - title: L10n.of(context).useActivityImageAsChatBackground, - onChanged: (b) => - AppConfig.useActivityImageAsChatBackground = b, - storeKey: SettingKeys.useActivityImageAsChatBackground, - defaultValue: AppConfig.useActivityImageAsChatBackground, + SwitchListTile.adaptive( + value: AppConfig.useActivityImageAsChatBackground, + title: Text(L10n.of(context).useActivityImageAsChatBackground), + onChanged: controller.setUseActivityImageBackground, ), // Divider(color: theme.dividerColor), // ListTile( diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index e90d6d9a9..71a4a5987 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -66,8 +66,10 @@ class PangeaController { }); subscriptionController.reinitialize(); - StyleSettingsRepo.fontSizeFactor(userID!).then((factor) { - AppConfig.fontSizeFactor = factor; + StyleSettingsRepo.settings(userID!).then((settings) { + AppConfig.fontSizeFactor = settings.fontSizeFactor; + AppConfig.useActivityImageAsChatBackground = + settings.useActivityImageBackground; }); } diff --git a/lib/pangea/user/style_settings_repo.dart b/lib/pangea/user/style_settings_repo.dart index 16fae1236..65f452fec 100644 --- a/lib/pangea/user/style_settings_repo.dart +++ b/lib/pangea/user/style_settings_repo.dart @@ -1,21 +1,39 @@ import 'package:get_storage/get_storage.dart'; -class _StyleSettings { - final double fontSizeFactor; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; - const _StyleSettings({ +class StyleSettings { + final double fontSizeFactor; + final bool useActivityImageBackground; + + const StyleSettings({ this.fontSizeFactor = 1.0, + this.useActivityImageBackground = true, }); Map toJson() { return { 'fontSizeFactor': fontSizeFactor, + 'useActivityImageBackground': useActivityImageBackground, }; } - factory _StyleSettings.fromJson(Map json) { - return _StyleSettings( + factory StyleSettings.fromJson(Map json) { + return StyleSettings( fontSizeFactor: (json['fontSizeFactor'] as num?)?.toDouble() ?? 1.0, + useActivityImageBackground: + json['useActivityImageBackground'] as bool? ?? true, + ); + } + + StyleSettings copyWith({ + double? fontSizeFactor, + bool? useActivityImageBackground, + }) { + return StyleSettings( + fontSizeFactor: fontSizeFactor ?? this.fontSizeFactor, + useActivityImageBackground: + useActivityImageBackground ?? this.useActivityImageBackground, ); } } @@ -23,18 +41,42 @@ class _StyleSettings { class StyleSettingsRepo { static final GetStorage _storage = GetStorage("style_settings"); - static Future fontSizeFactor(String userId) async { + static String _storageKey(String userId) => '${userId}_style_settings'; + + static Future settings(String userId) async { await GetStorage.init("style_settings"); - final json = - _storage.read>('${userId}_style_settings'); - final settings = - json != null ? _StyleSettings.fromJson(json) : const _StyleSettings(); - return settings.fontSizeFactor; + final key = _storageKey(userId); + final json = _storage.read>(key); + if (json == null) return const StyleSettings(); + try { + return StyleSettings.fromJson(json); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "settings_entry": json, + }, + ); + _storage.remove(key); + return const StyleSettings(); + } } static Future setFontSizeFactor(String userId, double factor) async { - await GetStorage.init("style_settings"); - final settings = _StyleSettings(fontSizeFactor: factor); - await _storage.write('${userId}_style_settings', settings.toJson()); + final currentSettings = await settings(userId); + final updatedSettings = currentSettings.copyWith(fontSizeFactor: factor); + await _storage.write(_storageKey(userId), updatedSettings.toJson()); + } + + static Future setUseActivityImageBackground( + String userId, + bool useBackground, + ) async { + final currentSettings = await settings(userId); + final updatedSettings = currentSettings.copyWith( + useActivityImageBackground: useBackground, + ); + await _storage.write(_storageKey(userId), updatedSettings.toJson()); } } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 808678ffb..c67c4a933 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -567,8 +567,10 @@ class MatrixState extends State with WidgetsBindingObserver { // double.tryParse(store.getString(SettingKeys.fontSizeFactor) ?? '') ?? // AppConfig.fontSizeFactor; if (client.isLogged()) { - StyleSettingsRepo.fontSizeFactor(client.userID!).then((factor) { - AppConfig.fontSizeFactor = factor; + StyleSettingsRepo.settings(client.userID!).then((settings) { + AppConfig.fontSizeFactor = settings.fontSizeFactor; + AppConfig.useActivityImageAsChatBackground = + settings.useActivityImageBackground; }); } // Pangea# From 5817ee5f0ad28de1b1a922814b4d3c071fb9d89b Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 3 Feb 2026 10:03:45 -0500 Subject: [PATCH 15/17] fix: one click to close word card in activity vocab --- lib/pangea/common/utils/overlay.dart | 1 + .../toolbar/reading_assistance/new_word_overlay.dart | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index c868db5bf..23bc2ff27 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -312,6 +312,7 @@ class OverlayUtil { closePrevOverlay: false, backDropToDismiss: false, ignorePointer: true, + canPop: false, ); } diff --git a/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart b/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart index 49e64163c..3f66827bb 100644 --- a/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart +++ b/lib/pangea/toolbar/reading_assistance/new_word_overlay.dart @@ -83,10 +83,12 @@ class _NewWordOverlayState extends State @override void dispose() { _controller?.dispose(); - MatrixState.pAnyState.closeOverlay(widget.transformTargetId); + MatrixState.pAnyState.closeOverlay(_overlayKey); super.dispose(); } + String get _overlayKey => "new-word-overlay-${widget.transformTargetId}"; + void _showFlyingWidget() { if (_controller == null || _opacityAnim == null || _moveAnim == null) { return; @@ -96,9 +98,10 @@ class _NewWordOverlayState extends State context: context, closePrevOverlay: false, ignorePointer: true, + canPop: false, offset: const Offset(0, 45), targetAnchor: Alignment.center, - overlayKey: widget.transformTargetId, + overlayKey: _overlayKey, transformTargetId: widget.transformTargetId, child: AnimatedBuilder( animation: _controller!, From 386275270f3d6d17a2454f35b63899ae02531d67 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 3 Feb 2026 10:28:47 -0500 Subject: [PATCH 16/17] fix: don't show error on cancel add recovery email --- lib/pages/settings_3pid/settings_3pid.dart | 3 +++ lib/pangea/learning_settings/settings_learning.dart | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pages/settings_3pid/settings_3pid.dart b/lib/pages/settings_3pid/settings_3pid.dart index 801427f85..9014c4155 100644 --- a/lib/pages/settings_3pid/settings_3pid.dart +++ b/lib/pages/settings_3pid/settings_3pid.dart @@ -61,6 +61,9 @@ class Settings3PidController extends State { auth: auth, ), ), + // #Pangea + showError: (e) => !e.toString().contains("Request has been canceled"), + // Pangea# ); if (success.error != null) return; setState(() => request = null); diff --git a/lib/pangea/learning_settings/settings_learning.dart b/lib/pangea/learning_settings/settings_learning.dart index d9bdcfa69..64986f00b 100644 --- a/lib/pangea/learning_settings/settings_learning.dart +++ b/lib/pangea/learning_settings/settings_learning.dart @@ -133,7 +133,6 @@ class SettingsLearningController extends State { waitForDataInSync: true, ), onError: (e, s) { - debugPrint("Error resetting instruction tooltips: $e"); debugger(when: kDebugMode); ErrorHandler.logError( e: e, From 8612f989c651025cbbf8e32095483fb50be0789c Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 3 Feb 2026 10:39:43 -0500 Subject: [PATCH 17/17] fix: filter edited events from search results --- lib/pages/chat_search/chat_search_page.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/pages/chat_search/chat_search_page.dart b/lib/pages/chat_search/chat_search_page.dart index 40109d0b6..f4a25e35d 100644 --- a/lib/pages/chat_search/chat_search_page.dart +++ b/lib/pages/chat_search/chat_search_page.dart @@ -76,10 +76,25 @@ class ChatSearchController extends State (result) => ( { for (final event in result.$1) event.eventId: event, - }.values.toList(), + // #Pangea + // }.values.toList(), + } + .values + .toList() + .where( + (e) => !e.hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + ), + ) + .toList(), + // Pangea# result.$2, ), ) + // #Pangea + .where((result) => result.$1.isNotEmpty) + // Pangea# .asBroadcastStream(); }); }