5702 audio practice tweaks (#5724)

* feat: add transcription and translation to audio practice

* fix: make sure answer form matches word in audio

and optimize distractor/answer selection with one pass through the list

* feat: audio practice progress bar

* fix: simplify audio logic
This commit is contained in:
avashilling 2026-02-18 09:52:15 -05:00 committed by GitHub
parent af9b9b4e1f
commit 03745fff89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 387 additions and 85 deletions

View file

@ -109,9 +109,13 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final ValueNotifier<int> hintsUsedNotifier = ValueNotifier<int>(0);
static const int maxHints = 5;
// Track number of correct answers selected for audio activities (for progress ovals)
final ValueNotifier<int> correctAnswersSelected = ValueNotifier<int>(0);
final Map<String, Map<String, String>> _choiceTexts = {};
final Map<String, Map<String, String?>> _choiceEmojis = {};
final Map<String, PangeaAudioFile> _audioFiles = {};
final Map<String, String> _audioTranslations = {};
StreamSubscription<void>? _languageStreamSubscription;
@ -140,6 +144,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
hintPressedNotifier.dispose();
showingAudioCompletion.dispose();
hintsUsedNotifier.dispose();
correctAnswersSelected.dispose();
super.dispose();
}
@ -235,6 +240,8 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
_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<AnalyticsPractice>
_continuing = true;
enableChoicesNotifier.value = true;
showingAudioCompletion.value = false;
correctAnswersSelected.value = 0;
try {
if (activityState.value
@ -465,7 +473,6 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
) 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<AnalyticsPractice>
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<AnalyticsPractice>
return _audioFiles[eventId];
}
String? getAudioTranslation(String? eventId) {
if (eventId == null) return null;
final translation = _audioTranslations[eventId];
return translation;
}
Future<void> _fetchLemmaInfo(
String requestKey,
List<String> choiceIds,
@ -551,12 +565,14 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
], _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<void> onAudioContinuePressed() async {
@ -632,6 +648,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
// 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),

View file

@ -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<InlineSpan> 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<bool>(
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<MultipleChoicePracticeActivityModel>) {
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<List<InlineSpan>?> 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<bool>(
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:

View file

@ -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,
),
],
),
);
}
}

View file

@ -93,7 +93,7 @@ class _GameChoiceCardState extends State<GameChoiceCard>
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

View file

@ -10,33 +10,51 @@ class VocabAudioActivityGenerator {
final token = req.target.tokens.first;
final audioExample = req.audioExampleMessage;
final Set<String> answers = {token.text.content.toLowerCase()};
final Set<String> 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<String> answers = {};
final Set<String> wordsInMessage = {};
final Set<String> lemmasInMessage = {};
final List<PangeaToken> answerTokens = [targetToken];
if (audioExample != null) {
// Collect all words/lemmas in message and select additional answer words
final List<PangeaToken> 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 = <PangeaToken>[];
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,

View file

@ -31,6 +31,9 @@ class PhoneticTranscriptionWidget extends StatefulWidget {
final VoidCallback? onTranscriptionFetched;
final ValueNotifier<int>? 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<PTResponse>(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(