diff --git a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart index fd34f5c9d..d9c619b32 100644 --- a/lib/pangea/analytics_details_popup/morph_meaning_widget.dart +++ b/lib/pangea/analytics_details_popup/morph_meaning_widget.dart @@ -16,12 +16,14 @@ class MorphMeaningWidget extends StatefulWidget { final MorphFeaturesEnum feature; final String tag; final TextStyle? style; + final bool blankErrorFeedback; const MorphMeaningWidget({ super.key, required this.feature, required this.tag, this.style, + this.blankErrorFeedback = false, }); @override @@ -91,12 +93,13 @@ class MorphMeaningWidgetState extends State { ); if (result.isError) { - return L10n.of(context).meaningNotFound; + return widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound; } final morph = result.result!.getFeatureByCode(widget.feature.name); final data = morph?.getTagByCode(widget.tag); - return data?.l1Description ?? L10n.of(context).meaningNotFound; + return data?.l1Description ?? + (widget.blankErrorFeedback ? '' : L10n.of(context).meaningNotFound); } void _toggleEditMode(bool value) => setState(() => _editMode = value); diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 6a7cc6fe5..7650a3a60 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dar import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; +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'; @@ -26,6 +27,16 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_contr import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; +class SelectedMorphChoice { + final MorphFeaturesEnum feature; + final String tag; + + const SelectedMorphChoice({ + required this.feature, + required this.tag, + }); +} + class VocabPracticeChoice { final String choiceId; final String choiceText; @@ -85,6 +96,9 @@ class AnalyticsPracticeState extends State final ValueNotifier progressNotifier = ValueNotifier(0.0); final ValueNotifier enableChoicesNotifier = ValueNotifier(true); + final ValueNotifier selectedMorphChoice = + ValueNotifier(null); + final Map> _choiceTexts = {}; final Map> _choiceEmojis = {}; @@ -108,6 +122,7 @@ class AnalyticsPracticeState extends State activityTarget.dispose(); progressNotifier.dispose(); enableChoicesNotifier.dispose(); + selectedMorphChoice.dispose(); super.dispose(); } @@ -192,8 +207,8 @@ class AnalyticsPracticeState extends State void _clearState() { activityState.value = const AsyncState.loading(); activityTarget.value = null; + selectedMorphChoice.value = null; enableChoicesNotifier.value = true; - progressNotifier.value = 0.0; _queue.clear(); _choiceTexts.clear(); @@ -256,6 +271,25 @@ class AnalyticsPracticeState extends State await _startSession(); } + Future reloadCurrentActivity() async { + if (activityTarget.value == null) return; + + try { + activityState.value = const AsyncState.loading(); + selectedMorphChoice.value = null; + + final req = activityTarget.value!; + final res = await _fetchActivity(req); + + if (!mounted) return; + activityState.value = AsyncState.loaded(res); + _playAudio(); + } catch (e) { + if (!mounted) return; + activityState.value = AsyncState.error(e); + } + } + Future _completeSession() async { _sessionLoader.value!.finishSession(); setState(() {}); @@ -284,6 +318,7 @@ class AnalyticsPracticeState extends State await _completeSession(); } else { activityState.value = const AsyncState.loading(); + selectedMorphChoice.value = null; final nextActivityCompleter = _queue.removeFirst(); activityTarget.value = nextActivityCompleter.request; @@ -410,6 +445,14 @@ class AnalyticsPracticeState extends State ) async { if (_currentActivity == null) return; final activity = _currentActivity!; + + // Track the selection for display + if (activity is MorphPracticeActivityModel) { + selectedMorphChoice.value = SelectedMorphChoice( + feature: activity.morphFeature, + tag: choiceContent, + ); + } final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent); if (isCorrect) { enableChoicesNotifier.value = false; diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 37fde47fd..3884dfc19 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -64,7 +65,7 @@ class AnalyticsPracticeSessionRepo { AnalyticsActivityTarget( target: PracticeTarget( tokens: [entry.key], - activityType: types[targets.length], + activityType: ActivityTypeEnum.grammarCategory, morphFeature: entry.value, ), ), @@ -243,18 +244,39 @@ class AnalyticsPracticeSessionRepo { } final choices = igcMatch!.match.choices!.map((c) => c.value).toList(); - final choiceTokens = tokens.where( - (token) => - token.lemma.saveVocab && - choices.any( - (choice) => choice.contains(token.text.content), - ), - ); + final choiceTokens = tokens + .where( + (token) => + token.lemma.saveVocab && + choices.any( + (choice) => choice.contains(token.text.content), + ), + ) + .toList(); + // Skip if no valid tokens found for this grammar error + if (choiceTokens.isEmpty) continue; + + String? translation; + try { + translation = await event.requestRespresentationByL1(); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'context': 'AnalyticsPracticeSessionRepo._fetchErrors', + 'message': 'Failed to fetch translation for analytics practice', + 'event_id': event.eventId, + }, + ); + } + + if (translation == null) continue; targets.add( AnalyticsActivityTarget( target: PracticeTarget( - tokens: choiceTokens.toList(), + tokens: choiceTokens, activityType: ActivityTypeEnum.grammarError, morphFeature: null, ), @@ -262,6 +284,7 @@ class AnalyticsPracticeSessionRepo { choreo: choreo, stepIndex: i, eventID: event.eventId, + translation: translation, ), ), ); diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 1d7c04031..21cef073d 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; @@ -74,8 +76,7 @@ class AnalyticsPracticeView extends StatelessWidget { ), body: Padding( padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 24.0, + horizontal: 8.0, ), child: MaxWidthBody( withScrolling: false, @@ -123,25 +124,36 @@ class _AnalyticsActivityView extends StatelessWidget { ), Expanded( child: Column( - spacing: 16.0, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Expanded( - flex: 1, - child: ValueListenableBuilder( - valueListenable: controller.activityTarget, - builder: (context, target, __) => target != null - ? Column( + ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, __) => target != null + ? Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + top: 16.0, + ), + child: Column( spacing: 12.0, children: [ Text( target.promptText(context), textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ), + style: FluffyThemes.isColumnMode(context) + ? Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ) + : Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + ), ), if (controller.widget.type == ConstructTypeEnum.vocab) @@ -153,22 +165,56 @@ class _AnalyticsActivityView extends StatelessWidget { style: const TextStyle(fontSize: 14.0), ), ], - ) - : const SizedBox(), - ), + ), + ) + : const SizedBox.shrink(), ), - Expanded( - flex: 2, - child: Center( + Flexible( + fit: FlexFit.loose, + child: SingleChildScrollView( child: _AnalyticsPracticeCenterContent( controller: controller, ), ), ), Expanded( - flex: 6, child: _ActivityChoicesWidget(controller), ), + //reserve space for grammar category morph meaning to avoid shifting, but only in those questions + AnimatedBuilder( + animation: Listenable.merge([ + controller.activityState, + controller.selectedMorphChoice, + ]), + builder: (context, _) { + final activityState = controller.activityState.value; + final selectedChoice = controller.selectedMorphChoice.value; + + final isGrammarCategory = activityState + is AsyncLoaded && + activityState.value.activityType == + ActivityTypeEnum.grammarCategory; + + if (!isGrammarCategory) { + return const SizedBox.shrink(); + } + + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 80, + ), + child: selectedChoice == null + ? const SizedBox.shrink() + : SingleChildScrollView( + child: MorphMeaningWidget( + feature: selectedChoice.feature, + tag: selectedChoice.tag, + blankErrorFeedback: true, + ), + ), + ); + }, + ), ], ), ), @@ -193,8 +239,23 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { ActivityTypeEnum.grammarError => ValueListenableBuilder( valueListenable: controller.activityState, builder: (context, state, __) => switch (state) { - AsyncLoaded(value: final activity) => _ErrorBlankWidget( - activity: activity as GrammarErrorPracticeActivityModel, + AsyncLoaded( + value: final GrammarErrorPracticeActivityModel activity + ) => + Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ErrorBlankWidget( + activity: activity, + ), + const SizedBox(height: 12), + _GrammarErrorTranslationButton( + key: ValueKey( + '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', + ), + translation: activity.translation, + ), + ], ), _ => const SizedBox(), }, @@ -332,7 +393,7 @@ class _ActivityChoicesWidget extends StatelessWidget { ErrorIndicator(message: error.toString()), const SizedBox(height: 16), TextButton.icon( - onPressed: controller.reloadSession, + onPressed: controller.reloadCurrentActivity, icon: const Icon(Icons.refresh), label: Text(L10n.of(context).tryAgain), ), @@ -347,31 +408,34 @@ class _ActivityChoicesWidget extends StatelessWidget { final cardHeight = (constrainedHeight / (choices.length + 1)) .clamp(50.0, 80.0); - return Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: ValueListenableBuilder( - valueListenable: controller.enableChoicesNotifier, - builder: (context, enabled, __) => Column( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - children: choices - .map( - (choice) => _ChoiceCard( - activity: value, - targetId: - controller.choiceTargetId(choice.choiceId), - choiceId: choice.choiceId, - onPressed: () => controller.onSelectChoice( - choice.choiceId, - ), - cardHeight: cardHeight, - choiceText: choice.choiceText, - choiceEmoji: choice.choiceEmoji, - enabled: enabled, - ), - ) - .toList(), - ), + return ValueListenableBuilder( + valueListenable: controller.enableChoicesNotifier, + builder: (context, enabled, __) => Column( + children: [ + Expanded( + child: Column( + spacing: 4.0, + mainAxisAlignment: MainAxisAlignment.center, + children: choices + .map( + (choice) => _ChoiceCard( + activity: value, + targetId: controller + .choiceTargetId(choice.choiceId), + choiceId: choice.choiceId, + onPressed: () => controller.onSelectChoice( + choice.choiceId, + ), + cardHeight: cardHeight, + choiceText: choice.choiceText, + choiceEmoji: choice.choiceEmoji, + enabled: enabled, + ), + ) + .toList(), + ), + ), + ], ), ); }, @@ -456,6 +520,7 @@ class _ChoiceCard extends StatelessWidget { tag: choiceText, onPressed: onPressed, isCorrect: isCorrect, + height: cardHeight, enabled: enabled, ); @@ -490,3 +555,85 @@ 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, + ), + ), + ], + ), + ), + ); + } +} 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 1bfa5c831..266ed1b8e 100644 --- a/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/grammar_choice_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; /// Choice card for meaning activity with emoji, and alt text on flip class GrammarChoiceCard extends StatelessWidget { @@ -31,6 +32,10 @@ class GrammarChoiceCard extends StatelessWidget { @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; final copy = getGrammarCopy( category: feature.name, lemma: tag, @@ -45,7 +50,33 @@ class GrammarChoiceCard extends StatelessWidget { isCorrect: isCorrect, height: height, isEnabled: enabled, - child: Text(copy), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: height * .7, + height: height, + child: Center( + child: MorphIcon( + morphFeature: feature, + morphTag: tag, + size: Size(emojiSize, emojiSize), + ), + ), + ), + Expanded( + child: Text( + copy, + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.left, + style: TextStyle( + fontSize: baseTextSize, + ), + ), + ), + ], + ), ); } } diff --git a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart index 30880ab50..854d1b66a 100644 --- a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart +++ b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart @@ -45,6 +45,7 @@ class GrammarErrorPracticeGenerator { errorOffset: igcMatch.offset, errorLength: igcMatch.length, eventID: eventID, + translation: req.grammarErrorInfo!.translation, ), ); } diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 47eebe6db..7452dd026 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -45,11 +45,13 @@ class GrammarErrorRequestInfo { final ChoreoRecordModel choreo; final int stepIndex; final String eventID; + final String translation; const GrammarErrorRequestInfo({ required this.choreo, required this.stepIndex, required this.eventID, + required this.translation, }); Map toJson() { @@ -57,6 +59,7 @@ class GrammarErrorRequestInfo { 'choreo': choreo.toJson(), 'step_index': stepIndex, 'event_id': eventID, + 'translation': translation, }; } @@ -65,6 +68,7 @@ class GrammarErrorRequestInfo { choreo: ChoreoRecordModel.fromJson(json['choreo']), stepIndex: json['step_index'] as int, eventID: json['event_id'] as String, + translation: json['translation'] as String, ); } } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 602165b7c..c0531dcba 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -187,6 +187,21 @@ sealed class PracticeActivityModel { tokens: tokens, matchContent: matchContent!, ); + case ActivityTypeEnum.grammarError: + assert( + multipleChoiceContent != null, + "multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarError", + ); + return GrammarErrorPracticeActivityModel( + langCode: langCode, + tokens: tokens, + multipleChoiceContent: multipleChoiceContent!, + text: json['text'] as String, + errorOffset: json['error_offset'] as int, + errorLength: json['error_length'] as int, + eventID: json['event_id'] as String, + translation: json['translation'] as String, + ); default: throw ("Unsupported activity type in PracticeActivityModel.fromJson: $type"); } @@ -358,6 +373,7 @@ class GrammarErrorPracticeActivityModel final int errorOffset; final int errorLength; final String eventID; + final String translation; GrammarErrorPracticeActivityModel({ required super.tokens, @@ -367,7 +383,19 @@ class GrammarErrorPracticeActivityModel required this.errorOffset, required this.errorLength, required this.eventID, + required this.translation, }); + + @override + Map toJson() { + final json = super.toJson(); + json['text'] = text; + json['error_offset'] = errorOffset; + json['error_length'] = errorLength; + json['event_id'] = eventID; + json['translation'] = translation; + return json; + } } class EmojiPracticeActivityModel extends MatchPracticeActivityModel {