diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index e4f0e3c3d..ec492a762 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -109,9 +109,13 @@ class AnalyticsPracticeState extends State final ValueNotifier hintsUsedNotifier = ValueNotifier(0); static const int maxHints = 5; + // Track number of correct answers selected for audio activities (for progress ovals) + final ValueNotifier correctAnswersSelected = ValueNotifier(0); + final Map> _choiceTexts = {}; final Map> _choiceEmojis = {}; final Map _audioFiles = {}; + final Map _audioTranslations = {}; StreamSubscription? _languageStreamSubscription; @@ -140,6 +144,7 @@ class AnalyticsPracticeState extends State hintPressedNotifier.dispose(); showingAudioCompletion.dispose(); hintsUsedNotifier.dispose(); + correctAnswersSelected.dispose(); super.dispose(); } @@ -235,6 +240,8 @@ class AnalyticsPracticeState extends State _queue.clear(); _choiceTexts.clear(); _choiceEmojis.clear(); + _audioFiles.clear(); + _audioTranslations.clear(); activityState.value = const AsyncState.idle(); AnalyticsPractice.bypassExitConfirmation = true; @@ -348,6 +355,7 @@ class AnalyticsPracticeState extends State _continuing = true; enableChoicesNotifier.value = true; showingAudioCompletion.value = false; + correctAnswersSelected.value = 0; try { if (activityState.value @@ -465,7 +473,6 @@ class AnalyticsPracticeState extends State ) async { final eventId = activity.eventId; final roomId = activity.roomId; - if (eventId == null || roomId == null) { throw L10n.of(context).oopsSomethingWentWrong; } @@ -493,9 +500,10 @@ class AnalyticsPracticeState extends State activity.langCode, MatrixState.pangeaController.userController.voice, ); - - // Store the audio file with the eventId as key + // Prefetch the translation + final translation = await pangeaEvent.requestRespresentationByL1(); _audioFiles[eventId] = audioFile; + _audioTranslations[eventId] = translation; } PangeaAudioFile? getAudioFile(String? eventId) { @@ -503,6 +511,12 @@ class AnalyticsPracticeState extends State return _audioFiles[eventId]; } + String? getAudioTranslation(String? eventId) { + if (eventId == null) return null; + final translation = _audioTranslations[eventId]; + return translation; + } + Future _fetchLemmaInfo( String requestKey, List choiceIds, @@ -551,12 +565,14 @@ class AnalyticsPracticeState extends State ], _l2!.langCodeShort); } - void onHintPressed() { - if (hintsUsedNotifier.value >= maxHints) return; - if (!hintPressedNotifier.value) { - hintsUsedNotifier.value++; + void onHintPressed({bool increment = true}) { + if (increment) { + if (hintsUsedNotifier.value >= maxHints) return; + if (!hintPressedNotifier.value) { + hintsUsedNotifier.value++; + } } - hintPressedNotifier.value = true; + hintPressedNotifier.value = !hintPressedNotifier.value; } Future onAudioContinuePressed() async { @@ -632,6 +648,7 @@ class AnalyticsPracticeState extends State // For audio activities, check if all correct answers have been clicked if (isAudioActivity) { + correctAnswersSelected.value++; final allAnswers = activity.multipleChoiceContent.answers; final allSelected = allAnswers.every( (answer) => _clickedChoices.contains(answer), diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 54134b950..20ed0a9de 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; @@ -9,6 +11,7 @@ import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.d 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'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart'; import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart'; @@ -185,9 +188,15 @@ class _AnalyticsActivityView extends StatelessWidget { child: _AnalyticsPracticeCenterContent(controller: controller), ), const SizedBox(height: 16.0), - (controller.widget.type == ConstructTypeEnum.morph) - ? Center(child: _HintSection(controller: controller)) - : const SizedBox.shrink(), + ValueListenableBuilder( + valueListenable: controller.activityTarget, + builder: (context, target, _) => + (controller.widget.type == ConstructTypeEnum.morph || + target?.target.activityType == + ActivityTypeEnum.lemmaAudio) + ? Center(child: _HintSection(controller: controller)) + : const SizedBox.shrink(), + ), const SizedBox(height: 16.0), _ActivityChoicesWidget(controller), const SizedBox(height: 16.0), @@ -250,7 +259,7 @@ class _AnalyticsPracticeCenterContent extends StatelessWidget { value: final VocabAudioPracticeActivityModel activity, ) => SizedBox( - height: 100.0, + height: 60.0, child: Center( child: AudioPlayerWidget( null, @@ -287,6 +296,16 @@ class _AudioCompletionWidget extends StatelessWidget { const _AudioCompletionWidget({super.key, required this.controller}); + String _extractTextFromSpans(List spans) { + final buffer = StringBuffer(); + for (final span in spans) { + if (span is TextSpan && span.text != null) { + buffer.write(span.text); + } + } + return buffer.toString(); + } + @override Widget build(BuildContext context) { final exampleMessage = controller.getAudioExampleMessage(); @@ -295,6 +314,8 @@ class _AudioCompletionWidget extends StatelessWidget { return const SizedBox(height: 100.0); } + final exampleText = _extractTextFromSpans(exampleMessage); + return Padding( padding: const EdgeInsets.all(16.0), child: Container( @@ -306,21 +327,102 @@ class _AudioCompletionWidget extends StatelessWidget { ), borderRadius: BorderRadius.circular(16), ), - child: RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryFixed, - fontSize: - AppSettings.fontSizeFactor.value * AppConfig.messageFontSize, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ValueListenableBuilder( + valueListenable: controller.hintPressedNotifier, + builder: (context, showPhonetics, _) => AnimatedSize( + duration: FluffyThemes.animationDuration, + alignment: Alignment.topCenter, + child: showPhonetics + ? Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: PhoneticTranscriptionWidget( + text: exampleText, + textLanguage: MatrixState + .pangeaController + .userController + .userL2!, + textOnly: true, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryFixed.withValues(alpha: 0.7), + fontSize: + (AppSettings.fontSizeFactor.value * + AppConfig.messageFontSize) * + 0.85, + fontStyle: FontStyle.italic, + ), + maxLines: 2, + ), + ) + : const SizedBox.shrink(), + ), ), - children: exampleMessage, - ), + + // Main example message + RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + fontSize: + AppSettings.fontSizeFactor.value * + AppConfig.messageFontSize, + ), + children: exampleMessage, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _AudioCompletionTranslation(controller: controller), + ), + ], ), ), ); } } +/// Widget to show translation for audio completion message +class _AudioCompletionTranslation extends StatelessWidget { + final AnalyticsPracticeState controller; + + const _AudioCompletionTranslation({required this.controller}); + + @override + Widget build(BuildContext context) { + final state = controller.activityState.value; + if (state is! AsyncLoaded) { + return const SizedBox.shrink(); + } + + final activity = state.value; + if (activity is! VocabAudioPracticeActivityModel) { + return const SizedBox.shrink(); + } + + final translation = controller.getAudioTranslation(activity.eventId); + if (translation == null) { + return const SizedBox.shrink(); + } + + return Text( + translation, + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryFixed.withValues(alpha: 0.8), + fontSize: + (AppSettings.fontSizeFactor.value * AppConfig.messageFontSize) * + 0.9, + fontStyle: FontStyle.italic, + ), + ); + } +} + class _ExampleMessageWidget extends StatelessWidget { final Future?> future; @@ -421,6 +523,18 @@ class _HintSection extends StatelessWidget { constraints: const BoxConstraints(minHeight: 50.0), child: Builder( builder: (context) { + final isAudioActivity = + activity.activityType == ActivityTypeEnum.lemmaAudio; + + // For audio activities: toggle hint on/off (no increment, no max hints) + if (isAudioActivity) { + return HintButton( + onPressed: () => controller.onHintPressed(increment: false), + depressed: hintPressed, + icon: Symbols.text_to_speech, + ); + } + // For grammar category: fade out button and show hint content if (activity is MorphPracticeActivityModel) { return AnimatedCrossFade( @@ -429,6 +543,7 @@ class _HintSection extends StatelessWidget { ? CrossFadeState.showSecond : CrossFadeState.showFirst, firstChild: HintButton( + icon: Icons.lightbulb_outline, onPressed: maxHintsReached ? () {} : controller.onHintPressed, @@ -443,6 +558,7 @@ class _HintSection extends StatelessWidget { // For grammar error: button stays pressed, hint shows in ErrorBlankWidget return HintButton( + icon: Icons.lightbulb_outline, onPressed: (hintPressed || maxHintsReached) ? () {} : controller.onHintPressed, @@ -636,11 +752,13 @@ class _ErrorBlankWidget extends StatelessWidget { class HintButton extends StatelessWidget { final VoidCallback onPressed; final bool depressed; + final IconData icon; const HintButton({ required this.onPressed, required this.depressed, super.key, + required this.icon, }); @override @@ -665,7 +783,7 @@ class HintButton extends StatelessWidget { shape: BoxShape.circle, ), ), - const Icon(Icons.lightbulb_outline, size: 20), + Icon(icon, size: 20), ], ), ); @@ -751,6 +869,7 @@ class _ActivityChoicesWidget extends StatelessWidget { onPressed: () => controller .onSelectChoice(choice.choiceId), cardHeight: 48.0, + controller: controller, choiceText: choice.choiceText, choiceEmoji: choice.choiceEmoji, enabled: enabled, @@ -777,6 +896,7 @@ class _ActivityChoicesWidget extends StatelessWidget { onPressed: () => controller.onSelectChoice(choice.choiceId), cardHeight: 60.0, + controller: controller, choiceText: choice.choiceText, choiceEmoji: choice.choiceEmoji, enabled: enabled, @@ -816,25 +936,67 @@ class _AudioContinueButton extends StatelessWidget { return const SizedBox.shrink(); } - return ValueListenableBuilder( - valueListenable: controller.showingAudioCompletion, - builder: (context, showingCompletion, _) { + final totalAnswers = activity.multipleChoiceContent.answers.length; + + return ListenableBuilder( + listenable: Listenable.merge([ + controller.showingAudioCompletion, + controller.correctAnswersSelected, + ]), + builder: (context, _) { + final showingCompletion = controller.showingAudioCompletion.value; + final correctSelected = controller.correctAnswersSelected.value; + return Padding( padding: const EdgeInsets.all(16.0), - child: ElevatedButton( - onPressed: showingCompletion - ? controller.onAudioContinuePressed - : null, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 48.0, - vertical: 16.0, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + // Progress ovals row + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + totalAnswers, + (index) => Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Container( + height: 16.0, + decoration: BoxDecoration( + color: index < correctSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8.0), + ), + ), + ), + ), + ), + ), + ), ), - ), - child: Text( - L10n.of(context).continueText, - style: const TextStyle(fontSize: 18.0), - ), + // Continue button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: showingCompletion + ? controller.onAudioContinuePressed + : null, + child: Text( + L10n.of(context).continueText, + style: const TextStyle(fontSize: 16.0), + ), + ), + ), + ], ), ); }, @@ -850,6 +1012,7 @@ class _ChoiceCard extends StatelessWidget { final String targetId; final VoidCallback onPressed; final double cardHeight; + final AnalyticsPracticeState controller; final String choiceText; final String? choiceEmoji; @@ -862,6 +1025,7 @@ class _ChoiceCard extends StatelessWidget { required this.targetId, required this.onPressed, required this.cardHeight, + required this.controller, required this.choiceText, required this.choiceEmoji, this.enabled = true, @@ -891,18 +1055,21 @@ class _ChoiceCard extends StatelessWidget { ); case ActivityTypeEnum.lemmaAudio: - return GameChoiceCard( - key: ValueKey( - '${constructId.string}_${activityType.name}_audio_$choiceId', + return ValueListenableBuilder( + valueListenable: controller.hintPressedNotifier, + builder: (context, showPhonetics, _) => AudioChoiceCard( + key: ValueKey( + '${constructId.string}_${activityType.name}_audio_$choiceId', + ), + choiceId: choiceId, + targetId: targetId, + displayText: choiceText, + textLanguage: MatrixState.pangeaController.userController.userL2!, + onPressed: onPressed, + isCorrect: isCorrect, + isEnabled: enabled, + showPhoneticTranscription: showPhonetics, ), - shouldFlip: false, - targetId: targetId, - onPressed: onPressed, - isCorrect: isCorrect, - height: cardHeight, - isEnabled: enabled, - shrinkWrap: shrinkWrap, - child: Text(choiceText, textAlign: TextAlign.center), ); case ActivityTypeEnum.grammarCategory: diff --git a/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart new file mode 100644 index 000000000..792e4918d --- /dev/null +++ b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; +import 'package:fluffychat/pangea/languages/language_model.dart'; +import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart'; + +/// Choice card for audio activity with phonetic transcription above the word +class AudioChoiceCard extends StatelessWidget { + final String choiceId; + final String targetId; + final String displayText; + final LanguageModel textLanguage; + final VoidCallback onPressed; + final bool isCorrect; + final bool isEnabled; + final bool showPhoneticTranscription; + + const AudioChoiceCard({ + required this.choiceId, + required this.targetId, + required this.displayText, + required this.textLanguage, + required this.onPressed, + required this.isCorrect, + this.isEnabled = true, + this.showPhoneticTranscription = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return GameChoiceCard( + shouldFlip: false, + targetId: targetId, + onPressed: onPressed, + isCorrect: isCorrect, + isEnabled: isEnabled, + shrinkWrap: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showPhoneticTranscription) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + PhoneticTranscriptionWidget( + text: displayText, + textLanguage: textLanguage, + textOnly: true, + style: theme.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + color: theme.textTheme.bodySmall?.color?.withValues( + alpha: 0.7, + ), + fontSize: 14, + ), + maxLines: 1, + ), + const SizedBox(height: 4), + ], + ), + // Main word text + Text( + displayText, + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart index 0fb452e4d..6db8296c2 100644 --- a/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart @@ -93,7 +93,7 @@ class _GameChoiceCardState extends State child: HoverBuilder( builder: (context, hovered) => SizedBox( width: widget.shrinkWrap ? null : double.infinity, - height: widget.height, + height: widget.shrinkWrap ? null : widget.height, child: GestureDetector( onTap: _handleTap, child: widget.shouldFlip diff --git a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index 963c74f71..714264a65 100644 --- a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -10,33 +10,51 @@ class VocabAudioActivityGenerator { final token = req.target.tokens.first; final audioExample = req.audioExampleMessage; - final Set answers = {token.text.content.toLowerCase()}; - final Set wordsInMessage = {}; + // Find the matching token in the audio example message to get the correct form + PangeaToken targetToken = token; if (audioExample != null) { - for (final t in audioExample.tokens) { - wordsInMessage.add(t.text.content.toLowerCase()); - } - - // Extract up to 3 additional words as answers, from shuffled message - audioExample.tokens.shuffle(); - final otherWords = audioExample.tokens - .where( - (t) => - t.lemma.saveVocab && - t.text.content.toLowerCase() != - token.text.content.toLowerCase() && - t.text.content.trim().isNotEmpty, - ) - .take(3) - .map((t) => t.text.content.toLowerCase()) - .toList(); - - answers.addAll(otherWords); + final matchingToken = audioExample.tokens.firstWhere( + (t) => t.lemma.text.toLowerCase() == token.lemma.text.toLowerCase(), + orElse: () => token, + ); + targetToken = matchingToken; } - // Generate distractors, filtering out anything in the message or answers + final Set answers = {}; + final Set wordsInMessage = {}; + final Set lemmasInMessage = {}; + final List answerTokens = [targetToken]; + + if (audioExample != null) { + // Collect all words/lemmas in message and select additional answer words + final List potentialAnswers = []; + + for (final t in audioExample.tokens) { + wordsInMessage.add(t.text.content.toLowerCase()); + lemmasInMessage.add(t.lemma.text.toLowerCase()); + + if (t != targetToken && + t.lemma.saveVocab && + t.text.content.trim().isNotEmpty) { + potentialAnswers.add(t); + } + } + + // Shuffle and select up to 3 additional answer words + potentialAnswers.shuffle(); + final otherAnswerTokens = potentialAnswers.take(3).toList(); + + answerTokens.addAll(otherAnswerTokens); + answers.addAll(answerTokens.map((t) => t.text.content.toLowerCase())); + } else { + answers.add(targetToken.text.content.toLowerCase()); + wordsInMessage.add(targetToken.text.content.toLowerCase()); + lemmasInMessage.add(targetToken.lemma.text.toLowerCase()); + } + + // Generate distractors, filtering out anything in the message (by form or lemma) final choices = await LemmaActivityGenerator.lemmaActivityDistractors( - token, + targetToken, maxChoices: 20, language: req.userL2.split('-').first, ); @@ -45,7 +63,8 @@ class VocabAudioActivityGenerator { .where( (lemma) => !answers.contains(lemma.toLowerCase()) && - !wordsInMessage.contains(lemma.toLowerCase()), + !wordsInMessage.contains(lemma.toLowerCase()) && + !lemmasInMessage.contains(lemma.toLowerCase()), ) .take(4) .toList(); @@ -53,18 +72,6 @@ class VocabAudioActivityGenerator { final allChoices = [...choicesList, ...answers]; allChoices.shuffle(); - final allTokens = audioExample?.tokens ?? req.target.tokens; - final answerTokens = []; - - answerTokens.add(token); - if (audioExample != null) { - for (final t in allTokens) { - if (t != token && answers.contains(t.text.content.toLowerCase())) { - answerTokens.add(t); - } - } - } - return MessageActivityResponse( activity: VocabAudioPracticeActivityModel( tokens: answerTokens, diff --git a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart index 3777b6d0a..e829316d9 100644 --- a/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart +++ b/lib/pangea/phonetic_transcription/phonetic_transcription_widget.dart @@ -31,6 +31,9 @@ class PhoneticTranscriptionWidget extends StatefulWidget { final VoidCallback? onTranscriptionFetched; final ValueNotifier? reloadNotifier; + /// If true, only show the transcription text without audio controls or hover effects + final bool textOnly; + const PhoneticTranscriptionWidget({ super.key, required this.text, @@ -43,6 +46,7 @@ class PhoneticTranscriptionWidget extends StatefulWidget { this.maxLines, this.onTranscriptionFetched, this.reloadNotifier, + this.textOnly = false, }); @override @@ -79,6 +83,37 @@ class _PhoneticTranscriptionWidgetState @override Widget build(BuildContext context) { final targetId = 'phonetic-transcription-${widget.text}-$hashCode'; + if (widget.textOnly) { + return PhoneticTranscriptionBuilder( + key: Key(targetId), + textLanguage: widget.textLanguage, + text: widget.text, + reloadNotifier: widget.reloadNotifier, + builder: (context, controller) { + return switch (controller.state) { + AsyncError() => const SizedBox.shrink(), + AsyncLoaded(value: final ptResponse) => Text( + disambiguate( + ptResponse.pronunciations, + pos: widget.pos, + morph: widget.morph, + ).displayTranscription, + textScaler: TextScaler.noScaling, + style: widget.style ?? Theme.of(context).textTheme.bodyMedium, + maxLines: widget.maxLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + _ => SizedBox( + width: 30.0, + height: 16.0, + child: TextLoadingShimmer(width: 30.0, height: 16.0), + ), + }; + }, + ); + } + return HoverBuilder( builder: (context, hovering) { return Tooltip(