5720 vocab practice should have feedback flag (#5761)

* chore: split up analytics activity page widgets into separate files

* started analytics practice refactor

* refactor how UI updates are triggered in analytics practice page

* some fixes
This commit is contained in:
ggurdin 2026-02-20 13:25:21 -05:00 committed by GitHub
parent d4884e6215
commit 117a03089e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1872 additions and 1735 deletions

View file

@ -0,0 +1,120 @@
import 'package:flutter/widgets.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';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityChoiceCard extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final String choiceId;
final String targetId;
final VoidCallback onPressed;
final double cardHeight;
final String choiceText;
final String? choiceEmoji;
final bool enabled;
final bool shrinkWrap;
final bool showHint;
const ActivityChoiceCard({
super.key,
required this.activity,
required this.choiceId,
required this.targetId,
required this.onPressed,
required this.cardHeight,
required this.choiceText,
required this.choiceEmoji,
required this.showHint,
this.enabled = true,
this.shrinkWrap = false,
});
@override
Widget build(BuildContext context) {
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId);
final activityType = activity.activityType;
final constructId = activity.tokens.first.vocabConstructID;
switch (activity.activityType) {
case ActivityTypeEnum.lemmaMeaning:
return MeaningChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_meaning_$choiceId',
),
choiceId: choiceId,
targetId: targetId,
displayText: choiceText,
emoji: choiceEmoji,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: enabled,
);
case ActivityTypeEnum.lemmaAudio:
return 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,
showHint: showHint,
);
case ActivityTypeEnum.grammarCategory:
return GrammarChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_grammar_$choiceId',
),
choiceId: choiceId,
targetId: targetId,
feature: (activity as MorphPracticeActivityModel).morphFeature,
tag: choiceText,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
enabled: enabled,
);
case ActivityTypeEnum.grammarError:
final activity = this.activity as GrammarErrorPracticeActivityModel;
return GameChoiceCard(
key: ValueKey(
'${activity.errorLength}_${activity.errorOffset}_${activity.eventID}_${activityType.name}_grammar_error_$choiceId',
),
shouldFlip: false,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: enabled,
child: Text(choiceText),
);
default:
return GameChoiceCard(
key: ValueKey(
'${constructId.string}_${activityType.name}_basic_$choiceId',
),
shouldFlip: false,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
isEnabled: enabled,
child: Text(choiceText),
);
}
}
}

View file

@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_choice_card_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_data_service.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_ui_controller.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityChoices extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final List<AnalyticsPracticeChoice> choices;
final ConstructTypeEnum type;
final bool isComplete;
final bool showHint;
final Function(String) onSelectChoice;
final List<InlineSpan>? audioExampleMessage;
final String? audioTranslation;
const ActivityChoices({
super.key,
required this.activity,
required this.choices,
required this.type,
required this.isComplete,
required this.showHint,
required this.onSelectChoice,
this.audioExampleMessage,
this.audioTranslation,
});
@override
Widget build(BuildContext context) {
if (activity.activityType == ActivityTypeEnum.lemmaAudio) {
// For audio activities, use AnimatedSwitcher to fade between choices and example message
return AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: isComplete
? _AudioCompletionWidget(
key: const ValueKey('completion'),
showHint: showHint,
exampleMessage: audioExampleMessage ?? [],
translation: audioTranslation ?? "",
)
: Padding(
key: const ValueKey('choices'),
padding: const EdgeInsets.all(16.0),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
runSpacing: 8.0,
children: choices
.map(
(choice) => ActivityChoiceCard(
activity: activity,
targetId:
AnalyticsPracticeUiController.getChoiceTargetId(
choice.choiceId,
type,
),
choiceId: choice.choiceId,
onPressed: () => onSelectChoice(choice.choiceId),
cardHeight: 48.0,
showHint: showHint,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: !isComplete,
shrinkWrap: true,
),
)
.toList(),
),
),
);
}
return Column(
spacing: 8.0,
mainAxisAlignment: MainAxisAlignment.center,
children: choices
.map(
(choice) => ActivityChoiceCard(
activity: activity,
targetId: AnalyticsPracticeUiController.getChoiceTargetId(
choice.choiceId,
type,
),
choiceId: choice.choiceId,
onPressed: () => onSelectChoice(choice.choiceId),
cardHeight: 60.0,
showHint: showHint,
choiceText: choice.choiceText,
choiceEmoji: choice.choiceEmoji,
enabled: !isComplete,
),
)
.toList(),
);
}
}
class _AudioCompletionWidget extends StatelessWidget {
final List<InlineSpan> exampleMessage;
final String translation;
final bool showHint;
const _AudioCompletionWidget({
super.key,
required this.exampleMessage,
required this.translation,
required this.showHint,
});
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) {
if (exampleMessage.isEmpty) {
return const SizedBox(height: 100.0);
}
final exampleText = _extractTextFromSpans(exampleMessage);
return Padding(
padding: const EdgeInsets.all(16.0),
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
alignment: Alignment.topCenter,
child: showHint
? Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: PhoneticTranscriptionWidget(
text: exampleText,
pos: 'other',
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(),
),
// 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(translation: translation),
),
],
),
),
);
}
}
/// Widget to show translation for audio completion message
class _AudioCompletionTranslation extends StatelessWidget {
final String translation;
const _AudioCompletionTranslation({required this.translation});
@override
Widget build(BuildContext context) {
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,
),
);
}
}

View file

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/pages/chat/events/audio_player.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_example_message_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/grammar_error_example_widget.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityContent extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final bool showHint;
final Future<List<InlineSpan>?> exampleMessage;
final PangeaAudioFile? audioFile;
const ActivityContent({
super.key,
required this.activity,
required this.showHint,
required this.exampleMessage,
this.audioFile,
});
@override
Widget build(BuildContext context) {
final activity = this.activity;
return switch (activity) {
GrammarErrorPracticeActivityModel() => SingleChildScrollView(
child: GrammarErrorExampleWidget(
key: ValueKey(
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
),
activity: activity,
showTranslation: showHint,
),
),
MorphCategoryPracticeActivityModel() => Center(
child: ActivityExampleMessage(exampleMessage),
),
VocabAudioPracticeActivityModel() => SizedBox(
height: 60.0,
child: Center(
child: AudioPlayerWidget(
null,
key: ValueKey('audio_${activity.eventId}'),
color: Theme.of(context).colorScheme.primary,
linkColor: Theme.of(context).colorScheme.secondary,
fontSize:
AppSettings.fontSizeFactor.value * AppConfig.messageFontSize,
eventId: '${activity.eventId}_practice',
roomId: activity.roomId!,
senderId: Matrix.of(context).client.userID!,
matrixFile: audioFile,
autoplay: true,
),
),
),
_ => SizedBox(
height: 100.0,
child: Center(child: ActivityExampleMessage(exampleMessage)),
),
};
}
}

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
class ActivityExampleMessage extends StatelessWidget {
final Future<List<InlineSpan>?> future;
const ActivityExampleMessage(this.future, {super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<List<InlineSpan>?>(
future: future,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox();
}
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:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
),
children: snapshot.data!,
),
),
);
},
);
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
class ActivityFeedback extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final SelectedMorphChoice selectedChoice;
const ActivityFeedback({
super.key,
required this.activity,
required this.selectedChoice,
});
@override
Widget build(BuildContext context) {
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,
),
);
}
}

View file

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
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
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,
),
),
Icon(icon, size: 20),
],
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_hint_button_widget.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
class ActivityHintSection extends StatelessWidget {
final MultipleChoicePracticeActivityModel activity;
final VoidCallback onPressed;
final bool enabled;
final bool hintPressed;
const ActivityHintSection({
super.key,
required this.activity,
required this.onPressed,
required this.enabled,
required this.hintPressed,
});
@override
Widget build(BuildContext context) {
final activity = this.activity;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 50.0),
child: switch (activity) {
VocabAudioPracticeActivityModel() => HintButton(
onPressed: onPressed,
depressed: hintPressed,
icon: Symbols.text_to_speech,
),
MorphPracticeActivityModel() => AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState: hintPressed
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: HintButton(
icon: Icons.lightbulb_outline,
onPressed: enabled ? onPressed : () {},
depressed: !enabled,
),
secondChild: MorphMeaningWidget(
feature: activity.morphFeature,
tag: activity.multipleChoiceContent.answers.first,
),
),
GrammarErrorPracticeActivityModel() => HintButton(
icon: Icons.lightbulb_outline,
onPressed: !enabled ? () {} : onPressed,
depressed: hintPressed || !enabled,
),
_ => SizedBox(),
},
);
}
}

View file

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
class ActivityHintsProgress extends StatelessWidget {
final int hintsUsed;
const ActivityHintsProgress({super.key, required this.hintsUsed});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
AnalyticsPracticeConstants.maxHints,
(index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Icon(
index < hintsUsed ? Icons.lightbulb : Icons.lightbulb_outline,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
);
}
}

View file

@ -0,0 +1,78 @@
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.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/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsPracticeAnalyticsController {
final AnalyticsDataService analyticsService;
const AnalyticsPracticeAnalyticsController(this.analyticsService);
Future<double> levelProgress(String language) async {
final derviedData = await analyticsService.derivedData(language);
return derviedData.levelProgress;
}
Future<void> addCompletedActivityAnalytics(
List<OneConstructUse> uses,
String targetId,
String language,
) => analyticsService.updateService.addAnalytics(targetId, uses, language);
Future<void> addSkippedActivityAnalytics(
PangeaToken token,
ConstructTypeEnum type,
String language,
) async {
final use = OneConstructUse(
useType: ConstructUseTypeEnum.ignPA,
constructType: type,
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
category: token.pos,
lemma: token.lemma.text,
form: token.lemma.text,
xp: 0,
);
await analyticsService.updateService.addAnalytics(null, [use], language);
}
Future<void> addSessionAnalytics(
List<OneConstructUse> uses,
String language,
) async {
await analyticsService.updateService.addAnalytics(
null,
uses,
language,
forceUpdate: true,
);
}
Future<ConstructUses> getTargetTokenConstruct(
PracticeTarget target,
String language,
) async {
final token = target.tokens.first;
final construct = target.targetTokenConstructID(token);
return analyticsService.getConstructUse(construct, language);
}
Future<void> waitForAnalytics() async {
if (!analyticsService.initCompleter.isCompleted) {
MatrixState.pangeaController.initControllers();
await analyticsService.initCompleter.future;
}
}
Future<void> waitForUpdate() => analyticsService
.updateDispatcher
.constructUpdateStream
.stream
.first
.timeout(const Duration(seconds: 10));
}

View file

@ -2,5 +2,6 @@ class AnalyticsPracticeConstants {
static const int timeForBonus = 60;
static const int practiceGroupSize = 10;
static const int errorBufferSize = 5;
static const int maxHints = 5;
static int get targetsToGenerate => practiceGroupSize + errorBufferSize;
}

View file

@ -0,0 +1,210 @@
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsPracticeChoice {
final String choiceId;
final String choiceText;
final String? choiceEmoji;
const AnalyticsPracticeChoice({
required this.choiceId,
required this.choiceText,
this.choiceEmoji,
});
}
class AnalyticsPracticeDataService {
final Map<String, Map<String, String>> _choiceTexts = {};
final Map<String, Map<String, String?>> _choiceEmojis = {};
final Map<String, PangeaAudioFile> _audioFiles = {};
final Map<String, String> _audioTranslations = {};
void clear() {
_choiceTexts.clear();
_choiceEmojis.clear();
_audioFiles.clear();
_audioTranslations.clear();
}
String _getChoiceText(String key, String choiceId, ConstructTypeEnum type) {
if (type == ConstructTypeEnum.morph) {
return choiceId;
}
if (_choiceTexts.containsKey(key) &&
_choiceTexts[key]!.containsKey(choiceId)) {
return _choiceTexts[key]![choiceId]!;
}
final cId = ConstructIdentifier.fromString(choiceId);
return cId?.lemma ?? choiceId;
}
String? _getChoiceEmoji(String key, String choiceId, ConstructTypeEnum type) {
if (type == ConstructTypeEnum.morph) return null;
return _choiceEmojis[key]?[choiceId];
}
PangeaAudioFile? getAudioFile(MultipleChoicePracticeActivityModel activity) {
if (activity is! VocabAudioPracticeActivityModel) return null;
if (activity.eventId == null) return null;
return _audioFiles[activity.eventId];
}
String? getAudioTranslation(MultipleChoicePracticeActivityModel activity) {
if (activity is! VocabAudioPracticeActivityModel) return null;
if (activity.eventId == null) return null;
final translation = _audioTranslations[activity.eventId];
return translation;
}
List<AnalyticsPracticeChoice> filteredChoices(
MultipleChoicePracticeActivityModel activity,
ConstructTypeEnum type,
) {
final content = activity.multipleChoiceContent;
final choices = content.choices.toList();
final answer = content.answers.first;
final filtered = <AnalyticsPracticeChoice>[];
final seenTexts = <String>{};
for (final id in choices) {
final text = _getChoiceText(activity.storageKey, id, type);
if (seenTexts.contains(text)) {
if (id != answer) {
continue;
}
final index = filtered.indexWhere(
(choice) => choice.choiceText == text,
);
if (index != -1) {
filtered[index] = AnalyticsPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: _getChoiceEmoji(activity.storageKey, id, type),
);
}
continue;
}
seenTexts.add(text);
filtered.add(
AnalyticsPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: _getChoiceEmoji(activity.storageKey, id, type),
),
);
}
return filtered;
}
void _setLemmaInfo(
String requestKey,
Map<String, String> texts,
Map<String, String?> emojis,
) {
_choiceTexts.putIfAbsent(requestKey, () => {});
_choiceEmojis.putIfAbsent(requestKey, () => {});
_choiceTexts[requestKey]!.addAll(texts);
_choiceEmojis[requestKey]!.addAll(emojis);
}
void _setAudioInfo(
String eventId,
PangeaAudioFile audioFile,
String translation,
) {
_audioFiles[eventId] = audioFile;
_audioTranslations[eventId] = translation;
}
Future<void> prefetchActivityInfo(
MultipleChoicePracticeActivityModel activity,
) async {
// Prefetch lemma info for meaning activities before marking ready
if (activity is VocabMeaningPracticeActivityModel) {
final choices = activity.multipleChoiceContent.choices.toList();
await _prefetchLemmaInfo(activity.storageKey, choices);
}
// Prefetch audio for audio activities before marking ready
if (activity is VocabAudioPracticeActivityModel) {
await _prefetchAudioInfo(activity);
}
}
Future<void> _prefetchAudioInfo(
VocabAudioPracticeActivityModel activity,
) async {
final eventId = activity.eventId;
final roomId = activity.roomId;
if (eventId == null || roomId == null) {
throw Exception();
}
final client = MatrixState.pangeaController.matrixState.client;
final room = client.getRoomById(roomId);
if (room == null) {
throw Exception();
}
final event = await room.getEventById(eventId);
if (event == null) {
throw Exception();
}
final pangeaEvent = PangeaMessageEvent(
event: event,
timeline: await room.getTimeline(),
ownMessage: event.senderId == client.userID,
);
// Prefetch the audio file
final audioFile = await pangeaEvent.requestTextToSpeech(
activity.langCode,
MatrixState.pangeaController.userController.voice,
);
if (audioFile.duration == null || audioFile.duration! <= 2000) {
throw "Audio file too short";
}
// Prefetch the translation
final translation = await pangeaEvent.requestRespresentationByL1();
_setAudioInfo(eventId, audioFile, translation);
}
Future<void> _prefetchLemmaInfo(
String requestKey,
List<String> choiceIds,
) async {
final texts = <String, String>{};
final emojis = <String, String?>{};
for (final id in choiceIds) {
final cId = ConstructIdentifier.fromString(id);
if (cId == null) continue;
final res = await cId.getLemmaInfo({});
if (res.isError) {
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
throw Exception();
}
texts[id] = res.result!.meaning;
emojis[id] = res.result!.emoji.firstOrNull;
}
_setLemmaInfo(requestKey, texts, emojis);
}
}

View file

@ -1,36 +1,21 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.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_misc/example_message_util.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_analytics_controller.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/analytics_practice/analytics_practice_session_repo.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_data_service.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_controller.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_ui_controller.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/languages/language_model.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/activity_type_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/text_to_speech/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class SelectedMorphChoice {
@ -40,38 +25,68 @@ class SelectedMorphChoice {
const SelectedMorphChoice({required this.feature, required this.tag});
}
class VocabPracticeChoice {
final String choiceId;
final String choiceText;
final String? choiceEmoji;
class AnalyticsPracticeNotifier extends ChangeNotifier {
String? _lastSelectedChoice;
bool showHint = false;
final Set<String> _clickedChoices = {};
const VocabPracticeChoice({
required this.choiceId,
required this.choiceText,
this.choiceEmoji,
});
}
int correctAnswersSelected(MultipleChoicePracticeActivityModel? activity) {
if (activity == null) return 0;
final allAnswers = activity.multipleChoiceContent.answers;
return _clickedChoices.where((c) => allAnswers.contains(c)).length;
}
class _PracticeQueueEntry {
final MessageActivityRequest request;
final Completer<MultipleChoicePracticeActivityModel> completer;
bool enableHintPress(
MultipleChoicePracticeActivityModel? activity,
int hintsUsed,
) {
if (showHint) return false;
return switch (activity) {
VocabAudioPracticeActivityModel() => true,
_ => hintsUsed < AnalyticsPracticeConstants.maxHints,
};
}
_PracticeQueueEntry({required this.request, required this.completer});
}
SelectedMorphChoice? selectedMorphChoice(
MultipleChoicePracticeActivityModel? activity,
) {
if (activity is! MorphPracticeActivityModel) return null;
if (_lastSelectedChoice == null) return null;
return SelectedMorphChoice(
feature: activity.morphFeature,
tag: _lastSelectedChoice!,
);
}
class SessionLoader extends AsyncLoader<AnalyticsPracticeSessionModel> {
final ConstructTypeEnum type;
SessionLoader({required this.type});
bool activityComplete(MultipleChoicePracticeActivityModel? activity) {
if (activity == null) return false;
final allAnswers = activity.multipleChoiceContent.answers;
return allAnswers.every((answer) => _clickedChoices.contains(answer));
}
@override
Future<AnalyticsPracticeSessionModel> fetch() {
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
if (l2 == null) throw Exception('User L2 language not set');
return AnalyticsPracticeSessionRepo.get(type, l2);
bool hasSelectedChoice(String choice) => _clickedChoices.contains(choice);
void clearActivityState() {
_lastSelectedChoice = null;
_clickedChoices.clear();
showHint = false;
}
void toggleShowHint() {
showHint = !showHint;
notifyListeners();
}
void selectChoice(String choice) {
_clickedChoices.add(choice);
_lastSelectedChoice = choice;
notifyListeners();
}
}
typedef ActivityNotifier =
ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>;
class AnalyticsPractice extends StatefulWidget {
static bool bypassExitConfirmation = true;
@ -84,47 +99,96 @@ class AnalyticsPractice extends StatefulWidget {
class AnalyticsPracticeState extends State<AnalyticsPractice>
with AnalyticsUpdater {
late final SessionLoader _sessionLoader;
final PracticeSessionController _sessionController =
PracticeSessionController();
final ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>
activityState = ValueNotifier(const AsyncState.idle());
final Queue<_PracticeQueueEntry> _queue = Queue();
final ValueNotifier<MessageActivityRequest?> activityTarget =
ValueNotifier<MessageActivityRequest?>(null);
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
final ValueNotifier<bool> enableChoicesNotifier = ValueNotifier<bool>(true);
final ValueNotifier<SelectedMorphChoice?> selectedMorphChoice =
ValueNotifier<SelectedMorphChoice?>(null);
final ValueNotifier<bool> hintPressedNotifier = ValueNotifier<bool>(false);
final Set<String> _clickedChoices = {};
// Track if we're showing the completion message for audio activities
final ValueNotifier<bool> showingAudioCompletion = ValueNotifier<bool>(false);
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 = {};
final AnalyticsPracticeDataService _dataService =
AnalyticsPracticeDataService();
late final AnalyticsPracticeAnalyticsController _analyticsController;
StreamSubscription<void>? _languageStreamSubscription;
final ActivityNotifier activityState = ActivityNotifier(
const AsyncState.idle(),
);
final AnalyticsPracticeNotifier notifier = AnalyticsPracticeNotifier();
final ValueNotifier<double> progress = ValueNotifier<double>(0);
@override
void initState() {
super.initState();
_sessionLoader = SessionLoader(type: widget.type);
_startSession();
_languageStreamSubscription = MatrixState
_analyticsController = AnalyticsPracticeAnalyticsController(
Matrix.of(context).analyticsDataService,
);
_addLanguageSubscription();
startSession();
}
@override
void dispose() {
_languageStreamSubscription?.cancel();
notifier.dispose();
activityState.dispose();
progress.dispose();
super.dispose();
}
PracticeSessionController get session => _sessionController;
AnalyticsPracticeDataService get data => _dataService;
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
MultipleChoicePracticeActivityModel? get activity {
final state = activityState.value;
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
return null;
}
return state.value;
}
Future<double> get levelProgress =>
_analyticsController.levelProgress(_l2!.langCodeShort);
Future<List<InlineSpan>?> get exampleMessage async {
final activity = this.activity;
if (activity == null) return null;
return switch (activity) {
VocabAudioPracticeActivityModel() =>
activity.exampleMessage.exampleMessage,
MorphCategoryPracticeActivityModel() =>
activity.exampleMessageInfo.exampleMessage,
_ => ExampleMessageUtil.getExampleMessage(
await _analyticsController.getTargetTokenConstruct(
activity.practiceTarget,
_l2!.langCodeShort,
),
),
};
}
bool _autoLaunchNextActivity(MultipleChoicePracticeActivityModel activity) =>
activity is! VocabAudioPracticeActivityModel;
void _clearState() {
_dataService.clear();
_sessionController.clear();
AnalyticsPractice.bypassExitConfirmation = true;
_clearActivityState();
}
void _clearActivityState({bool loadingActivity = false}) {
notifier.clearActivityState();
activityState.value = loadingActivity
? AsyncState.loading()
: AsyncState.idle();
}
void _addLanguageSubscription() {
_languageStreamSubscription ??= MatrixState
.pangeaController
.userController
.languageStream
@ -132,620 +196,130 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
.listen((_) => _onLanguageUpdate());
}
@override
void dispose() {
_languageStreamSubscription?.cancel();
_sessionLoader.dispose();
activityState.dispose();
activityTarget.dispose();
progressNotifier.dispose();
enableChoicesNotifier.dispose();
selectedMorphChoice.dispose();
hintPressedNotifier.dispose();
showingAudioCompletion.dispose();
hintsUsedNotifier.dispose();
correctAnswersSelected.dispose();
super.dispose();
}
MultipleChoicePracticeActivityModel? get _currentActivity =>
activityState.value is AsyncLoaded<MultipleChoicePracticeActivityModel>
? (activityState.value
as AsyncLoaded<MultipleChoicePracticeActivityModel>)
.value
: null;
bool get _isComplete => _sessionLoader.value?.isComplete ?? false;
ValueNotifier<AsyncState<AnalyticsPracticeSessionModel>> get sessionState =>
_sessionLoader.state;
AnalyticsDataService get _analyticsService =>
Matrix.of(context).analyticsDataService;
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
List<VocabPracticeChoice> filteredChoices(
MultipleChoicePracticeActivityModel activity,
) {
final content = activity.multipleChoiceContent;
final choices = content.choices.toList();
final answer = content.answers.first;
final filtered = <VocabPracticeChoice>[];
final seenTexts = <String>{};
for (final id in choices) {
final text = getChoiceText(activity.storageKey, id);
if (seenTexts.contains(text)) {
if (id != answer) {
continue;
}
final index = filtered.indexWhere(
(choice) => choice.choiceText == text,
);
if (index != -1) {
filtered[index] = VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
);
}
continue;
}
seenTexts.add(text);
filtered.add(
VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
),
);
}
return filtered;
}
String getChoiceText(String key, String choiceId) {
if (widget.type == ConstructTypeEnum.morph) {
return choiceId;
}
if (_choiceTexts.containsKey(key) &&
_choiceTexts[key]!.containsKey(choiceId)) {
return _choiceTexts[key]![choiceId]!;
}
final cId = ConstructIdentifier.fromString(choiceId);
return cId?.lemma ?? choiceId;
}
String? getChoiceEmoji(String key, String choiceId) {
if (widget.type == ConstructTypeEnum.morph) return null;
return _choiceEmojis[key]?[choiceId];
}
String choiceTargetId(String choiceId) =>
'${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}';
void _clearState() {
activityState.value = const AsyncState.loading();
activityTarget.value = null;
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
hintsUsedNotifier.value = 0;
enableChoicesNotifier.value = true;
progressNotifier.value = 0.0;
showingAudioCompletion.value = false;
_queue.clear();
_choiceTexts.clear();
_choiceEmojis.clear();
_audioFiles.clear();
_audioTranslations.clear();
activityState.value = const AsyncState.idle();
AnalyticsPractice.bypassExitConfirmation = true;
}
void updateElapsedTime(int seconds) {
if (_sessionLoader.isLoaded) {
_sessionLoader.value!.setElapsedSeconds(seconds);
}
}
void _playAudio() {
if (activityTarget.value == null) return;
if (widget.type == ConstructTypeEnum.vocab &&
_currentActivity is VocabMeaningPracticeActivityModel) {
} else {
return;
}
final token = activityTarget.value!.target.tokens.first;
TtsController.tryToSpeak(
token.vocabConstructID.lemma,
langCode: _l2!.langCode,
pos: token.pos,
morph: token.morph.map((k, v) => MapEntry(k.name, v)),
);
}
Future<void> _waitForAnalytics() async {
if (!_analyticsService.initCompleter.isCompleted) {
MatrixState.pangeaController.initControllers();
await _analyticsService.initCompleter.future;
}
}
Future<void> _onLanguageUpdate() async {
try {
_clearState();
await _analyticsService
.updateDispatcher
.constructUpdateStream
.stream
.first
.timeout(const Duration(seconds: 10));
await reloadSession();
await _analyticsController.waitForUpdate();
await startSession();
} catch (e) {
if (mounted) {
activityState.value = AsyncState.error(
L10n.of(context).oopsSomethingWentWrong,
);
activityState.value = AsyncState.error(e);
}
}
}
Future<void> _startSession() async {
await _waitForAnalytics();
await _sessionLoader.load();
if (_sessionLoader.isError) {
AnalyticsPractice.bypassExitConfirmation = true;
return;
}
progressNotifier.value = _sessionLoader.value!.progress;
await _continueSession();
void onHintPressed({bool increment = true}) {
if (increment) _sessionController.updateHintsPressed();
notifier.toggleShowHint();
}
Future<void> reloadSession() async {
void _playActivityAudio(MultipleChoicePracticeActivityModel activity) =>
AnalyticsPracticeUiController.playTargetAudio(
activity,
widget.type,
_l2!.langCodeShort,
);
Future<void> startSession() async {
_clearState();
_sessionLoader.reset();
await _startSession();
}
await _analyticsController.waitForAnalytics();
await _sessionController.startSession(widget.type);
if (mounted) setState(() {});
Future<void> reloadCurrentActivity() async {
if (activityTarget.value == null) return;
try {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
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);
if (_sessionController.sessionError != null) {
AnalyticsPractice.bypassExitConfirmation = true;
} else {
progress.value = _sessionController.progress;
await _continueSession();
}
}
Future<void> _completeSession() async {
_sessionLoader.value!.finishSession();
_sessionController.completeSession();
setState(() {});
final bonus = _sessionLoader.value!.state.allBonusUses;
await _analyticsService.updateService.addAnalytics(
null,
bonus,
_l2!.langCodeShort,
forceUpdate: true,
);
final bonus = _sessionController.bonusUses;
await _analyticsController.addSessionAnalytics(bonus, _l2!.langCodeShort);
AnalyticsPractice.bypassExitConfirmation = true;
}
bool _continuing = false;
Future<void> _continueSession() async {
if (_continuing) return;
_continuing = true;
enableChoicesNotifier.value = true;
showingAudioCompletion.value = false;
correctAnswersSelected.value = 0;
if (activityState.value
is AsyncLoading<MultipleChoicePracticeActivityModel>) {
return;
}
_clearActivityState(loadingActivity: true);
try {
if (activityState.value
is AsyncIdle<MultipleChoicePracticeActivityModel>) {
await _initActivityData();
} else {
// Keep trying to load activities from the queue until one succeeds or queue is empty
while (_queue.isNotEmpty) {
activityState.value = const AsyncState.loading();
selectedMorphChoice.value = null;
hintPressedNotifier.value = false;
_clickedChoices.clear();
final nextActivityCompleter = _queue.removeFirst();
final resp = await _sessionController.getNextActivity(
skipActivity,
_dataService.prefetchActivityInfo,
);
try {
final activity = await nextActivityCompleter.completer.future;
activityTarget.value = nextActivityCompleter.request;
_playAudio();
activityState.value = AsyncState.loaded(activity);
AnalyticsPractice.bypassExitConfirmation = false;
return;
} catch (e) {
// Completer failed, skip to next
continue;
}
}
// Queue is empty, complete the session
if (resp != null) {
_playActivityAudio(resp);
AnalyticsPractice.bypassExitConfirmation = false;
activityState.value = AsyncState.loaded(resp);
} else {
await _completeSession();
}
} catch (e) {
AnalyticsPractice.bypassExitConfirmation = true;
activityState.value = AsyncState.error(e);
} finally {
_continuing = false;
}
}
Future<void> _initActivityData() async {
final requests = _sessionLoader.value!.activityRequests;
if (requests.isEmpty) {
throw L10n.of(context).noActivityRequest;
}
for (var i = 0; i < requests.length; i++) {
try {
activityState.value = const AsyncState.loading();
final req = requests[i];
final res = await _fetchActivity(req);
if (!mounted) return;
activityTarget.value = req;
_playAudio();
activityState.value = AsyncState.loaded(res);
AnalyticsPractice.bypassExitConfirmation = false;
// Fill queue with remaining requests
_fillActivityQueue(requests.skip(i + 1).toList());
return;
} catch (e) {
await recordSkippedUse(requests[i]);
// Try next request
continue;
}
}
AnalyticsPractice.bypassExitConfirmation = true;
if (!mounted) return;
activityState.value = AsyncState.error(
L10n.of(context).oopsSomethingWentWrong,
);
return;
}
Future<void> _fillActivityQueue(List<MessageActivityRequest> requests) async {
for (final request in requests) {
final completer = Completer<MultipleChoicePracticeActivityModel>();
_queue.add(_PracticeQueueEntry(request: request, completer: completer));
try {
final res = await _fetchActivity(request);
if (!mounted) return;
completer.complete(res);
} catch (e) {
if (!mounted) return;
completer.completeError(e);
await recordSkippedUse(request);
}
}
}
Future<MultipleChoicePracticeActivityModel> _fetchActivity(
MessageActivityRequest req,
) async {
final result = await PracticeRepo.getPracticeActivity(req, messageInfo: {});
if (result.isError ||
result.result is! MultipleChoicePracticeActivityModel) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final activityModel = result.result as MultipleChoicePracticeActivityModel;
// Prefetch lemma info for meaning activities before marking ready
if (activityModel is VocabMeaningPracticeActivityModel) {
final choices = activityModel.multipleChoiceContent.choices.toList();
await _fetchLemmaInfo(activityModel.storageKey, choices);
}
// Prefetch audio for audio activities before marking ready
if (activityModel is VocabAudioPracticeActivityModel) {
await _loadAudioForActivity(activityModel);
}
return activityModel;
}
Future<void> _loadAudioForActivity(
VocabAudioPracticeActivityModel activity,
) async {
final eventId = activity.eventId;
final roomId = activity.roomId;
if (eventId == null || roomId == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final client = MatrixState.pangeaController.matrixState.client;
final room = client.getRoomById(roomId);
if (room == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final event = await room.getEventById(eventId);
if (event == null) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final pangeaEvent = PangeaMessageEvent(
event: event,
timeline: await room.getTimeline(),
ownMessage: event.senderId == client.userID,
);
// Prefetch the audio file
final audioFile = await pangeaEvent.requestTextToSpeech(
activity.langCode,
MatrixState.pangeaController.userController.voice,
);
if (audioFile.duration == null || audioFile.duration! <= 2000) {
throw "Audio file too short";
}
// Prefetch the translation
final translation = await pangeaEvent.requestRespresentationByL1();
_audioFiles[eventId] = audioFile;
_audioTranslations[eventId] = translation;
}
PangeaAudioFile? getAudioFile(String? eventId) {
if (eventId == null) return null;
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,
) async {
final texts = <String, String>{};
final emojis = <String, String?>{};
for (final id in choiceIds) {
final cId = ConstructIdentifier.fromString(id);
if (cId == null) continue;
final res = await cId.getLemmaInfo({});
if (res.isError) {
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
throw L10n.of(context).oopsSomethingWentWrong;
}
texts[id] = res.result!.meaning;
emojis[id] = res.result!.emoji.firstOrNull;
}
_choiceTexts.putIfAbsent(requestKey, () => {});
_choiceEmojis.putIfAbsent(requestKey, () => {});
_choiceTexts[requestKey]!.addAll(texts);
_choiceEmojis[requestKey]!.addAll(emojis);
}
Future<void> recordSkippedUse(MessageActivityRequest request) async {
// Record a 0 XP use so that activity isn't chosen again soon
_sessionLoader.value!.incrementSkippedActivities();
final token = request.target.tokens.first;
final use = OneConstructUse(
useType: ConstructUseTypeEnum.ignPA,
constructType: widget.type,
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
category: token.pos,
lemma: token.lemma.text,
form: token.lemma.text,
xp: 0,
);
await _analyticsService.updateService.addAnalytics(null, [
use,
], _l2!.langCodeShort);
}
void onHintPressed({bool increment = true}) {
if (increment) {
if (hintsUsedNotifier.value >= maxHints) return;
if (!hintPressedNotifier.value) {
hintsUsedNotifier.value++;
}
}
hintPressedNotifier.value = !hintPressedNotifier.value;
}
Future<void> onAudioContinuePressed() async {
showingAudioCompletion.value = false;
//Mark this activity as completed, and either load the next or complete the session
_sessionLoader.value!.completeActivity();
progressNotifier.value = _sessionLoader.value!.progress;
if (_queue.isEmpty) {
await _completeSession();
} else if (_isComplete) {
await _completeSession();
} else {
await _continueSession();
}
}
Future<void> onSelectChoice(String choiceContent) async {
if (_currentActivity == null) return;
final activity = _currentActivity!;
final activity = this.activity;
if (activity == null) return;
// Mark this choice as clicked so it can't be clicked again
if (_clickedChoices.contains(choiceContent)) {
return;
} else {
setState(() {
_clickedChoices.add(choiceContent);
});
}
// Track the selection for display
if (activity is MorphPracticeActivityModel) {
selectedMorphChoice.value = SelectedMorphChoice(
feature: activity.morphFeature,
tag: choiceContent,
);
}
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent);
final isAudioActivity =
activity.activityType == ActivityTypeEnum.lemmaAudio;
if (isCorrect && !isAudioActivity) {
// Non-audio activities disable choices after first correct answer
enableChoicesNotifier.value = false;
}
// Update activity record
// For audio activities, find the token that matches the clicked word
final tokenForChoice = isAudioActivity
? activity.tokens.firstWhere(
(t) => t.text.content.toLowerCase() == choiceContent.toLowerCase(),
orElse: () => activity.tokens.first,
)
: activity.tokens.first;
PracticeRecordController.onSelectChoice(
choiceContent,
tokenForChoice,
activity,
);
if (notifier.hasSelectedChoice(choiceContent)) return;
notifier.selectChoice(choiceContent);
final uses = activity.constructUses(choiceContent);
_sessionLoader.value!.submitAnswer(uses);
await _analyticsService.updateService.addAnalytics(
choiceTargetId(choiceContent),
_sessionController.submitAnswer(uses);
await _analyticsController.addCompletedActivityAnalytics(
uses,
AnalyticsPracticeUiController.getChoiceTargetId(
choiceContent,
widget.type,
),
_l2!.langCodeShort,
);
if (!isCorrect) return;
if (!notifier.activityComplete(activity)) return;
// 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),
_playActivityAudio(activity);
if (_autoLaunchNextActivity(activity)) {
await Future.delayed(
const Duration(milliseconds: 1000),
startNextActivity,
);
if (!allSelected) {
return;
}
// All answers selected, disable choices and show completion message
enableChoicesNotifier.value = false;
await Future.delayed(const Duration(milliseconds: 1000));
showingAudioCompletion.value = true;
return;
}
_playAudio();
// Display the fact that the choice was correct before loading the next activity
await Future.delayed(const Duration(milliseconds: 1000));
// Then mark this activity as completed, and either load the next or complete the session
_sessionLoader.value!.completeActivity();
progressNotifier.value = _sessionLoader.value!.progress;
if (_queue.isEmpty) {
await _completeSession();
} else if (_isComplete) {
await _completeSession();
} else {
await _continueSession();
}
}
Future<List<InlineSpan>?> getExampleMessage(
MessageActivityRequest activityRequest,
) async {
final target = activityRequest.target;
final token = target.tokens.first;
final construct = target.targetTokenConstructID(token);
Future<void> startNextActivity() async {
_sessionController.completeActivity();
progress.value = _sessionController.progress;
if (widget.type == ConstructTypeEnum.morph) {
return activityRequest.exampleMessage?.exampleMessage;
}
_sessionController.session?.isComplete == true
? await _completeSession()
: await _continueSession();
}
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct, _l2!.langCodeShort),
Future<void> skipActivity(MessageActivityRequest request) async {
// Record a 0 XP use so that activity isn't chosen again soon
_sessionController.skipActivity();
await _analyticsController.addSkippedActivityAnalytics(
request.target.tokens.first,
widget.type,
_l2!.langCodeShort,
);
}
List<InlineSpan>? getAudioExampleMessage() {
final activity = _currentActivity;
if (activity is VocabAudioPracticeActivityModel) {
return activity.exampleMessage.exampleMessage;
}
return null;
}
Future<DerivedAnalyticsDataModel> get derivedAnalyticsData =>
_analyticsService.derivedData(_l2!.langCodeShort);
/// Returns congratulations message based on performance
String getCompletionMessage(BuildContext context) {
final accuracy = _sessionLoader.value?.state.accuracy ?? 0;
final hasTimeBonus =
(_sessionLoader.value?.state.elapsedSeconds ?? 0) <=
AnalyticsPracticeConstants.timeForBonus;
final hintsUsed = hintsUsedNotifier.value;
final bool perfectAccuracy = accuracy == 100;
final bool noHintsUsed = hintsUsed == 0;
final bool hintsAvailable = widget.type == ConstructTypeEnum.morph;
//check how many conditions for bonuses the user met and return message accordingly
final conditionsMet = [
perfectAccuracy,
!hintsAvailable || noHintsUsed,
hasTimeBonus,
].where((c) => c).length;
if (conditionsMet == 3) {
return L10n.of(context).perfectPractice;
}
if (conditionsMet >= 2) {
return L10n.of(context).greatPractice;
}
if (hintsAvailable && noHintsUsed) {
return L10n.of(context).usedNoHints;
}
return L10n.of(context).youveCompletedPractice;
}
@override
Widget build(BuildContext context) => AnalyticsPracticeView(this);
}

View file

@ -0,0 +1,176 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_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/analytics_practice/analytics_practice_session_repo.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _PracticeQueueEntry {
final MessageActivityRequest request;
final Completer<MultipleChoicePracticeActivityModel> completer;
_PracticeQueueEntry({required this.request, required this.completer});
}
class PracticeSessionController {
PracticeSessionController();
AnalyticsPracticeSessionModel? session;
bool isLoadingSession = false;
Object? sessionError;
final Queue<_PracticeQueueEntry> _queue = Queue();
void clear() {
_queue.clear();
}
List<MessageActivityRequest> get activityRequests =>
session?.activityRequests ?? [];
List<OneConstructUse> get bonusUses => session?.state.allBonusUses ?? [];
int get hintsUsed => session?.state.hintsUsed ?? 0;
double get progress => session?.progress ?? 0;
String getCompletionMessage(BuildContext context) =>
session?.getCompletionMessage(context) ??
L10n.of(context).youveCompletedPractice;
void updateElapsedTime(int seconds) {
session?.setElapsedSeconds(seconds);
}
void updateHintsPressed() {
session?.useHint();
}
void updateElapsedSeconds(int seconds) {
session?.setElapsedSeconds(seconds);
}
void completeActivity() {
session?.completeActivity();
}
void skipActivity() {
session?.incrementSkippedActivities();
}
void submitAnswer(List<OneConstructUse> uses) {
session?.submitAnswer(uses);
}
Future<void> startSession(ConstructTypeEnum type) async {
try {
isLoadingSession = true;
sessionError = null;
session = null;
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
if (l2 == null) throw Exception('User L2 language not set');
session = await AnalyticsPracticeSessionRepo.get(type, l2);
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {});
sessionError = e;
} finally {
isLoadingSession = false;
}
}
Future<void> completeSession() async {
session?.finishSession();
}
Future<MultipleChoicePracticeActivityModel?> _initActivityData(
Future Function(MessageActivityRequest) onSkip,
Future Function(MultipleChoicePracticeActivityModel) onFetch,
) async {
final requests = activityRequests;
for (var i = 0; i < requests.length; i++) {
try {
final req = requests[i];
final res = await _fetchActivity(req, onFetch);
_fillActivityQueue(requests.skip(i + 1).toList(), onSkip, onFetch);
return res;
} catch (e) {
await onSkip(requests[i]);
// Try next request
continue;
}
}
return null;
}
Future<void> _fillActivityQueue(
List<MessageActivityRequest> requests,
Future Function(MessageActivityRequest) onSkip,
Future Function(MultipleChoicePracticeActivityModel) onFetch,
) async {
for (final request in requests) {
final completer = Completer<MultipleChoicePracticeActivityModel>();
_queue.add(_PracticeQueueEntry(request: request, completer: completer));
try {
final res = await _fetchActivity(request, onFetch);
completer.complete(res);
} catch (e) {
completer.completeError(e);
await onSkip(request);
}
}
}
Future<MultipleChoicePracticeActivityModel> _fetchActivity(
MessageActivityRequest req,
Future Function(MultipleChoicePracticeActivityModel) onFetch,
) async {
final result = await PracticeRepo.getPracticeActivity(req, messageInfo: {});
if (result.isError ||
result.result is! MultipleChoicePracticeActivityModel) {
throw Exception();
}
final activityModel = result.result as MultipleChoicePracticeActivityModel;
await onFetch(activityModel);
return activityModel;
}
Future<MultipleChoicePracticeActivityModel?> getNextActivity(
Future Function(MessageActivityRequest) onSkip,
Future Function(MultipleChoicePracticeActivityModel) onFetch,
) async {
if (session == null) {
throw Exception("Called getNextActivity without loading session");
}
if (!session!.isComplete && _queue.isEmpty) {
return _initActivityData(onSkip, onFetch);
}
while (_queue.isNotEmpty) {
final nextActivityCompleter = _queue.removeFirst();
try {
final activity = await nextActivityCompleter.completer.future;
return activity;
} catch (e) {
// Completer failed, skip to next
continue;
}
}
return null;
}
}

View file

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.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_constants.dart';
@ -113,6 +115,7 @@ class AnalyticsActivityTarget {
class AnalyticsPracticeSessionModel {
final DateTime startedAt;
final ConstructTypeEnum type;
final List<AnalyticsActivityTarget> practiceTargets;
final String userL1;
final String userL2;
@ -121,6 +124,7 @@ class AnalyticsPracticeSessionModel {
AnalyticsPracticeSessionModel({
required this.startedAt,
required this.type,
required this.practiceTargets,
required this.userL1,
required this.userL2,
@ -173,6 +177,35 @@ class AnalyticsPracticeSessionModel {
}).toList();
}
/// Returns congratulations message based on performance
String getCompletionMessage(BuildContext context) {
final hasTimeBonus =
state.elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus;
final hintsUsed = state.hintsUsed;
final bool perfectAccuracy = state.accuracy == 100;
final bool noHintsUsed = hintsUsed == 0;
final bool hintsAvailable = type == ConstructTypeEnum.morph;
//check how many conditions for bonuses the user met and return message accordingly
final conditionsMet = [
perfectAccuracy,
!hintsAvailable || noHintsUsed,
hasTimeBonus,
].where((c) => c).length;
if (conditionsMet == 3) {
return L10n.of(context).perfectPractice;
}
if (conditionsMet >= 2) {
return L10n.of(context).greatPractice;
}
if (hintsAvailable && noHintsUsed) {
return L10n.of(context).usedNoHints;
}
return L10n.of(context).youveCompletedPractice;
}
void setElapsedSeconds(int seconds) =>
state = state.copyWith(elapsedSeconds: seconds);
@ -187,9 +220,15 @@ class AnalyticsPracticeSessionModel {
void submitAnswer(List<OneConstructUse> uses) =>
state = state.copyWith(completedUses: [...state.completedUses, ...uses]);
void useHint() => state = state.copyWith(hintsUsed: state.hintsUsed + 1);
factory AnalyticsPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return AnalyticsPracticeSessionModel(
startedAt: DateTime.parse(json['startedAt'] as String),
type: ConstructTypeEnum.values.firstWhere(
(e) => e.name == json['type'] as String,
orElse: () => ConstructTypeEnum.vocab,
),
practiceTargets: (json['practiceTargets'] as List<dynamic>)
.map((e) => AnalyticsActivityTarget.fromJson(e))
.whereType<AnalyticsActivityTarget>()
@ -203,6 +242,7 @@ class AnalyticsPracticeSessionModel {
Map<String, dynamic> toJson() {
return {
'startedAt': startedAt.toIso8601String(),
'type': type.name,
'practiceTargets': practiceTargets.map((e) => e.toJson()).toList(),
'userL1': userL1,
'userL2': userL2,
@ -215,6 +255,8 @@ class AnalyticsPracticeSessionState {
final List<OneConstructUse> completedUses;
final int currentIndex;
final bool finished;
final int hintsUsed;
final int elapsedSeconds;
final int skippedActivities;
@ -222,6 +264,7 @@ class AnalyticsPracticeSessionState {
this.completedUses = const [],
this.currentIndex = 0,
this.finished = false,
this.hintsUsed = 0,
this.elapsedSeconds = 0,
this.skippedActivities = 0,
});
@ -275,6 +318,7 @@ class AnalyticsPracticeSessionState {
List<OneConstructUse>? completedUses,
int? currentIndex,
bool? finished,
int? hintsUsed,
int? elapsedSeconds,
int? skippedActivities,
}) {
@ -282,6 +326,7 @@ class AnalyticsPracticeSessionState {
completedUses: completedUses ?? this.completedUses,
currentIndex: currentIndex ?? this.currentIndex,
finished: finished ?? this.finished,
hintsUsed: hintsUsed ?? this.hintsUsed,
elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds,
skippedActivities: skippedActivities ?? this.skippedActivities,
);
@ -292,6 +337,7 @@ class AnalyticsPracticeSessionState {
'completedUses': completedUses.map((e) => e.toJson()).toList(),
'currentIndex': currentIndex,
'finished': finished,
'hintsUsed': hintsUsed,
'elapsedSeconds': elapsedSeconds,
'skippedActivities': skippedActivities,
};
@ -307,6 +353,7 @@ class AnalyticsPracticeSessionState {
[],
currentIndex: json['currentIndex'] as int? ?? 0,
finished: json['finished'] as bool? ?? false,
hintsUsed: json['hintsUsed'] as int? ?? 0,
elapsedSeconds: json['elapsedSeconds'] as int? ?? 0,
skippedActivities: json['skippedActivities'] as int? ?? 0,
);

View file

@ -77,6 +77,7 @@ class AnalyticsPracticeSessionRepo {
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
type: type,
practiceTargets: targets,
);
return session;

View file

@ -0,0 +1,24 @@
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
class AnalyticsPracticeUiController {
static String getChoiceTargetId(String choiceId, ConstructTypeEnum type) =>
'${type.name}-choice-card-${choiceId.replaceAll(' ', '_')}';
static void playTargetAudio(
MultipleChoicePracticeActivityModel activity,
ConstructTypeEnum type,
String language,
) {
if (activity is! VocabMeaningPracticeActivityModel) return;
final token = activity.tokens.first;
TtsController.tryToSpeak(
token.vocabConstructID.lemma,
langCode: language,
pos: token.pos,
morph: token.morph.map((k, v) => MapEntry(k.name, v)),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
class AudioContinueButton extends StatelessWidget {
final VocabAudioPracticeActivityModel activity;
final bool activityComplete;
final int correctAnswers;
final VoidCallback onContinue;
const AudioContinueButton({
super.key,
required this.activity,
required this.onContinue,
required this.activityComplete,
required this.correctAnswers,
});
@override
Widget build(BuildContext context) {
final activity = this.activity;
final totalAnswers = activity.multipleChoiceContent.answers.length;
return Padding(
padding: const EdgeInsets.all(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 < correctAnswers
? Theme.of(context).colorScheme.primary
: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8.0),
),
),
),
),
),
),
),
),
// Continue button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: activityComplete ? onContinue : null,
child: Text(
L10n.of(context).continueText,
style: const TextStyle(fontSize: 16.0),
),
),
),
],
),
);
}
}

View file

@ -13,7 +13,7 @@ class AudioChoiceCard extends StatelessWidget {
final VoidCallback onPressed;
final bool isCorrect;
final bool isEnabled;
final bool showPhoneticTranscription;
final bool showHint;
const AudioChoiceCard({
required this.choiceId,
@ -23,7 +23,7 @@ class AudioChoiceCard extends StatelessWidget {
required this.onPressed,
required this.isCorrect,
this.isEnabled = true,
this.showPhoneticTranscription = false,
this.showHint = false,
super.key,
});
@ -41,7 +41,7 @@ class AudioChoiceCard extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showPhoneticTranscription)
if (showHint)
Column(
mainAxisSize: MainAxisSize.min,
children: [

View file

@ -6,7 +6,6 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.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/percent_marker_bar.dart';
import 'package:fluffychat/pangea/analytics_practice/stat_card.dart';
@ -16,10 +15,13 @@ import 'package:fluffychat/widgets/matrix.dart';
class CompletedActivitySessionView extends StatelessWidget {
final AnalyticsPracticeSessionModel session;
final AnalyticsPracticeState controller;
const CompletedActivitySessionView(
this.session,
this.controller, {
final VoidCallback launchSession;
final Future<double> levelProgress;
const CompletedActivitySessionView({
required this.session,
required this.launchSession,
required this.levelProgress,
super.key,
});
@ -47,7 +49,7 @@ class CompletedActivitySessionView extends StatelessWidget {
child: Column(
children: [
Text(
controller.getCompletionMessage(context),
session.getCompletionMessage(context),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
@ -81,12 +83,10 @@ class CompletedActivitySessionView extends StatelessWidget {
bottom: 16.0,
),
child: FutureBuilder(
future: controller.derivedAnalyticsData,
future: levelProgress,
builder: (context, snapshot) => AnimatedProgressBar(
height: 20.0,
widthPercent: snapshot.hasData
? snapshot.data!.levelProgress
: 0.0,
widthPercent: snapshot.data ?? 0.0,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
@ -144,7 +144,7 @@ class CompletedActivitySessionView extends StatelessWidget {
vertical: 8.0,
),
),
onPressed: () => controller.reloadSession(),
onPressed: launchSession,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [Text(L10n.of(context).anotherRound)],

View file

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
class GrammarErrorExampleWidget extends StatelessWidget {
final GrammarErrorPracticeActivityModel activity;
final bool showTranslation;
const GrammarErrorExampleWidget({
super.key,
required this.activity,
required this.showTranslation,
});
@override
Widget build(BuildContext context) {
final text = activity.text;
final errorOffset = activity.errorOffset;
final errorLength = activity.errorLength;
const maxContextChars = 50;
final chars = text.characters;
final totalLength = chars.length;
// ---------- BEFORE ----------
int beforeStart = 0;
bool trimmedBefore = false;
if (errorOffset > maxContextChars) {
int desiredStart = errorOffset - maxContextChars;
// Snap left to nearest whitespace to avoid cutting words
while (desiredStart > 0 && chars.elementAt(desiredStart) != ' ') {
desiredStart--;
}
beforeStart = desiredStart;
trimmedBefore = true;
}
final before = chars
.skip(beforeStart)
.take(errorOffset - beforeStart)
.toString();
// ---------- AFTER ----------
int afterEnd = totalLength;
bool trimmedAfter = false;
final errorEnd = errorOffset + errorLength;
final afterChars = totalLength - errorEnd;
if (afterChars > maxContextChars) {
int desiredEnd = errorEnd + maxContextChars;
// Snap right to nearest whitespace
while (desiredEnd < totalLength && chars.elementAt(desiredEnd) != ' ') {
desiredEnd++;
}
afterEnd = desiredEnd;
trimmedAfter = true;
}
final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString();
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: Column(
mainAxisSize: MainAxisSize.min,
children: [
RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
),
children: [
if (trimmedBefore) const TextSpan(text: ''),
if (before.isNotEmpty) TextSpan(text: before),
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 (after.isNotEmpty) TextSpan(text: after),
if (trimmedAfter) const TextSpan(text: ''),
],
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: showTranslation
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Text(
activity.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize:
AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
)
: const SizedBox.shrink(),
),
],
),
);
}
}

View file

@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_choices_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_content_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_feedback_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_hint_section_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/activity_hints_progress_widget.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
import 'package:fluffychat/pangea/analytics_practice/audio_activity_continue_button_widget.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.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';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
class OngoingActivitySessionView extends StatelessWidget {
final AnalyticsPracticeState controller;
const OngoingActivitySessionView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
TextStyle? titleStyle = isColumnMode
? Theme.of(context).textTheme.titleLarge
: Theme.of(context).textTheme.titleMedium;
titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold);
return ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, _) {
final activity = controller.activity;
return Column(
children: [
Expanded(
child: ListView(
children: [
//Hints counter bar for grammar activities only
if (controller.widget.type == ConstructTypeEnum.morph)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: ActivityHintsProgress(
hintsUsed: controller.session.hintsUsed,
),
),
//per-activity instructions, add switch statement once there are more types
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.selectMeaning,
padding: EdgeInsets.symmetric(vertical: 8.0),
),
SizedBox(
height: 75.0,
child: Builder(
builder: (context) {
if (activity == null) {
return const SizedBox.shrink();
}
final isAudioActivity =
activity.activityType ==
ActivityTypeEnum.lemmaAudio;
final isVocabType =
controller.widget.type == ConstructTypeEnum.vocab;
final token = activity.tokens.first;
return Column(
children: [
Text(
isAudioActivity && isVocabType
? L10n.of(context).selectAllWords
: activity.practiceTarget.promptText(context),
textAlign: TextAlign.center,
style: titleStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (isVocabType && !isAudioActivity)
PhoneticTranscriptionWidget(
text: token.vocabConstructID.lemma,
pos: token.pos,
morph: token.morph.map(
(k, v) => MapEntry(k.name, v),
),
textLanguage: MatrixState
.pangeaController
.userController
.userL2!,
style: const TextStyle(fontSize: 14.0),
),
],
);
},
),
),
ListenableBuilder(
listenable: controller.notifier,
builder: (context, _) {
final selectedMorphChoice = controller.notifier
.selectedMorphChoice(activity);
return Column(
children: [
const SizedBox(height: 16.0),
if (activity != null)
Center(
child: ActivityContent(
activity: activity,
showHint: controller.notifier.showHint,
exampleMessage: controller.exampleMessage,
audioFile: controller.data.getAudioFile(
activity,
),
),
),
const SizedBox(height: 16.0),
if (activity != null)
ActivityHintSection(
activity: activity,
onPressed: controller.onHintPressed,
hintPressed: controller.notifier.showHint,
enabled: controller.notifier.enableHintPress(
activity,
controller.session.hintsUsed,
),
),
const SizedBox(height: 16.0),
switch (state) {
AsyncError(error: final error) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//allow try to reload activity in case of error
ErrorIndicator(
message: error.toLocalizedString(context),
),
const SizedBox(height: 16),
TextButton.icon(
onPressed: controller.startSession,
icon: const Icon(Icons.refresh),
label: Text(L10n.of(context).tryAgain),
),
],
),
AsyncLoaded(value: final activity) => Builder(
builder: (context) {
List<InlineSpan>? audioExampleMessage;
String? audioTranslation;
if (activity
is VocabAudioPracticeActivityModel) {
audioExampleMessage =
activity.exampleMessage.exampleMessage;
audioTranslation = controller.data
.getAudioTranslation(activity);
}
return ActivityChoices(
activity: activity,
choices: controller.data.filteredChoices(
activity,
controller.widget.type,
),
type: controller.widget.type,
isComplete: controller.notifier
.activityComplete(activity),
showHint: controller.notifier.showHint,
onSelectChoice: controller.onSelectChoice,
audioExampleMessage: audioExampleMessage,
audioTranslation: audioTranslation,
);
},
),
_ => Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
),
},
const SizedBox(height: 16.0),
if (activity != null && selectedMorphChoice != null)
ActivityFeedback(
activity: activity,
selectedChoice: selectedMorphChoice,
),
],
);
},
),
],
),
),
if (activity is VocabAudioPracticeActivityModel)
ListenableBuilder(
listenable: controller.notifier,
builder: (context, _) => Container(
alignment: Alignment.bottomCenter,
child: AudioContinueButton(
activity: activity,
onContinue: controller.startNextActivity,
activityComplete: controller.notifier.activityComplete(
activity,
),
correctAnswers: controller.notifier.correctAnswersSelected(
activity,
),
),
),
),
],
);
},
);
}
}

View file

@ -1,12 +1,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';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
@ -97,20 +92,6 @@ class MessageActivityRequest {
}
}
String promptText(BuildContext context) {
switch (target.activityType) {
case ActivityTypeEnum.grammarCategory:
return L10n.of(context).whatIsTheMorphTag(
target.morphFeature!.getDisplayCopy(context),
target.tokens.first.text.content,
);
case ActivityTypeEnum.grammarError:
return L10n.of(context).fillInBlank;
default:
return target.tokens.first.vocabConstructID.lemma;
}
}
Map<String, dynamic> toJson() {
return {
'user_l1': userL1,

View file

@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
@ -34,6 +36,20 @@ class PracticeTarget {
}
}
String promptText(BuildContext context) {
switch (activityType) {
case ActivityTypeEnum.grammarCategory:
return L10n.of(context).whatIsTheMorphTag(
morphFeature!.getDisplayCopy(context),
tokens.first.text.content,
);
case ActivityTypeEnum.grammarError:
return L10n.of(context).fillInBlank;
default:
return tokens.first.vocabConstructID.lemma;
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;