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:
parent
af9b9b4e1f
commit
03745fff89
6 changed files with 387 additions and 85 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue