From 117a03089e49bbd364cf03b1339d9dc5314cf232 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:25:21 -0500 Subject: [PATCH] 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 --- .../activity_choice_card_widget.dart | 120 ++ .../activity_choices_widget.dart | 225 ++++ .../activity_content_widget.dart | 68 + .../activity_example_message_widget.dart | 44 + .../activity_feedback_widget.dart | 36 + .../activity_hint_button_widget.dart | 44 + .../activity_hint_section_widget.dart | 59 + .../activity_hints_progress_widget.dart | 30 + ...alytics_practice_analytics_controller.dart | 78 ++ .../analytics_practice_constants.dart | 1 + .../analytics_practice_data_service.dart | 210 ++++ .../analytics_practice_page.dart | 850 ++++--------- ...analytics_practice_session_controller.dart | 176 +++ .../analytics_practice_session_model.dart | 47 + .../analytics_practice_session_repo.dart | 1 + .../analytics_practice_ui_controller.dart | 24 + .../analytics_practice_view.dart | 1092 +---------------- ...audio_activity_continue_button_widget.dart | 79 ++ .../choice_cards/audio_choice_card.dart | 6 +- .../completed_activity_session_view.dart | 22 +- .../grammar_error_example_widget.dart | 138 +++ .../ongoing_activity_session_view.dart | 222 ++++ .../message_activity_request.dart | 19 - .../practice_activities/practice_target.dart | 16 + 24 files changed, 1872 insertions(+), 1735 deletions(-) create mode 100644 lib/pangea/analytics_practice/activity_choice_card_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_choices_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_content_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_example_message_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_feedback_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_hint_button_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_hint_section_widget.dart create mode 100644 lib/pangea/analytics_practice/activity_hints_progress_widget.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_analytics_controller.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_data_service.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_session_controller.dart create mode 100644 lib/pangea/analytics_practice/analytics_practice_ui_controller.dart create mode 100644 lib/pangea/analytics_practice/audio_activity_continue_button_widget.dart create mode 100644 lib/pangea/analytics_practice/grammar_error_example_widget.dart create mode 100644 lib/pangea/analytics_practice/ongoing_activity_session_view.dart diff --git a/lib/pangea/analytics_practice/activity_choice_card_widget.dart b/lib/pangea/analytics_practice/activity_choice_card_widget.dart new file mode 100644 index 000000000..930a4ae0c --- /dev/null +++ b/lib/pangea/analytics_practice/activity_choice_card_widget.dart @@ -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), + ); + } + } +} diff --git a/lib/pangea/analytics_practice/activity_choices_widget.dart b/lib/pangea/analytics_practice/activity_choices_widget.dart new file mode 100644 index 000000000..986f19de4 --- /dev/null +++ b/lib/pangea/analytics_practice/activity_choices_widget.dart @@ -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 choices; + final ConstructTypeEnum type; + + final bool isComplete; + final bool showHint; + final Function(String) onSelectChoice; + + final List? 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 exampleMessage; + final String translation; + final bool showHint; + + const _AudioCompletionWidget({ + super.key, + required this.exampleMessage, + required this.translation, + required this.showHint, + }); + + String _extractTextFromSpans(List spans) { + final buffer = StringBuffer(); + for (final span in spans) { + if (span is TextSpan && span.text != null) { + buffer.write(span.text); + } + } + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + 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, + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/activity_content_widget.dart b/lib/pangea/analytics_practice/activity_content_widget.dart new file mode 100644 index 000000000..0245f993e --- /dev/null +++ b/lib/pangea/analytics_practice/activity_content_widget.dart @@ -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?> 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)), + ), + }; + } +} diff --git a/lib/pangea/analytics_practice/activity_example_message_widget.dart b/lib/pangea/analytics_practice/activity_example_message_widget.dart new file mode 100644 index 000000000..52ce495cd --- /dev/null +++ b/lib/pangea/analytics_practice/activity_example_message_widget.dart @@ -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?> future; + + const ActivityExampleMessage(this.future, {super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + 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!, + ), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/analytics_practice/activity_feedback_widget.dart b/lib/pangea/analytics_practice/activity_feedback_widget.dart new file mode 100644 index 000000000..5693b8f6e --- /dev/null +++ b/lib/pangea/analytics_practice/activity_feedback_widget.dart @@ -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, + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/activity_hint_button_widget.dart b/lib/pangea/analytics_practice/activity_hint_button_widget.dart new file mode 100644 index 000000000..f14e0229c --- /dev/null +++ b/lib/pangea/analytics_practice/activity_hint_button_widget.dart @@ -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), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/activity_hint_section_widget.dart b/lib/pangea/analytics_practice/activity_hint_section_widget.dart new file mode 100644 index 000000000..89867df7e --- /dev/null +++ b/lib/pangea/analytics_practice/activity_hint_section_widget.dart @@ -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(), + }, + ); + } +} diff --git a/lib/pangea/analytics_practice/activity_hints_progress_widget.dart b/lib/pangea/analytics_practice/activity_hints_progress_widget.dart new file mode 100644 index 000000000..8ddc9e073 --- /dev/null +++ b/lib/pangea/analytics_practice/activity_hints_progress_widget.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/analytics_practice_analytics_controller.dart b/lib/pangea/analytics_practice/analytics_practice_analytics_controller.dart new file mode 100644 index 000000000..d9fe255a9 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_analytics_controller.dart @@ -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 levelProgress(String language) async { + final derviedData = await analyticsService.derivedData(language); + return derviedData.levelProgress; + } + + Future addCompletedActivityAnalytics( + List uses, + String targetId, + String language, + ) => analyticsService.updateService.addAnalytics(targetId, uses, language); + + Future 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 addSessionAnalytics( + List uses, + String language, + ) async { + await analyticsService.updateService.addAnalytics( + null, + uses, + language, + forceUpdate: true, + ); + } + + Future getTargetTokenConstruct( + PracticeTarget target, + String language, + ) async { + final token = target.tokens.first; + final construct = target.targetTokenConstructID(token); + return analyticsService.getConstructUse(construct, language); + } + + Future waitForAnalytics() async { + if (!analyticsService.initCompleter.isCompleted) { + MatrixState.pangeaController.initControllers(); + await analyticsService.initCompleter.future; + } + } + + Future waitForUpdate() => analyticsService + .updateDispatcher + .constructUpdateStream + .stream + .first + .timeout(const Duration(seconds: 10)); +} diff --git a/lib/pangea/analytics_practice/analytics_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart index a6e50ee56..2a465313c 100644 --- a/lib/pangea/analytics_practice/analytics_practice_constants.dart +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -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; } diff --git a/lib/pangea/analytics_practice/analytics_practice_data_service.dart b/lib/pangea/analytics_practice/analytics_practice_data_service.dart new file mode 100644 index 000000000..0ee73e6e7 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_data_service.dart @@ -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> _choiceTexts = {}; + final Map> _choiceEmojis = {}; + final Map _audioFiles = {}; + final Map _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 filteredChoices( + MultipleChoicePracticeActivityModel activity, + ConstructTypeEnum type, + ) { + final content = activity.multipleChoiceContent; + final choices = content.choices.toList(); + final answer = content.answers.first; + final filtered = []; + + final seenTexts = {}; + 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 texts, + Map 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 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 _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 _prefetchLemmaInfo( + String requestKey, + List choiceIds, + ) async { + final texts = {}; + final emojis = {}; + + 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); + } +} diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index e7acf5795..d302488df 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -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 _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 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 { - 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 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>; + class AnalyticsPractice extends StatefulWidget { static bool bypassExitConfirmation = true; @@ -84,47 +99,96 @@ class AnalyticsPractice extends StatefulWidget { class AnalyticsPracticeState extends State with AnalyticsUpdater { - late final SessionLoader _sessionLoader; + final PracticeSessionController _sessionController = + PracticeSessionController(); - final ValueNotifier> - activityState = ValueNotifier(const AsyncState.idle()); - - final Queue<_PracticeQueueEntry> _queue = Queue(); - - final ValueNotifier activityTarget = - ValueNotifier(null); - - final ValueNotifier progressNotifier = ValueNotifier(0.0); - final ValueNotifier enableChoicesNotifier = ValueNotifier(true); - - final ValueNotifier selectedMorphChoice = - ValueNotifier(null); - - final ValueNotifier hintPressedNotifier = ValueNotifier(false); - - final Set _clickedChoices = {}; - - // Track if we're showing the completion message for audio activities - final ValueNotifier showingAudioCompletion = ValueNotifier(false); - final ValueNotifier hintsUsedNotifier = ValueNotifier(0); - static const int maxHints = 5; - - // Track number of correct answers selected for audio activities (for progress ovals) - final ValueNotifier correctAnswersSelected = ValueNotifier(0); - - final Map> _choiceTexts = {}; - final Map> _choiceEmojis = {}; - final Map _audioFiles = {}; - final Map _audioTranslations = {}; + final AnalyticsPracticeDataService _dataService = + AnalyticsPracticeDataService(); + late final AnalyticsPracticeAnalyticsController _analyticsController; StreamSubscription? _languageStreamSubscription; + final ActivityNotifier activityState = ActivityNotifier( + const AsyncState.idle(), + ); + final AnalyticsPracticeNotifier notifier = AnalyticsPracticeNotifier(); + final ValueNotifier progress = ValueNotifier(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) { + return null; + } + + return state.value; + } + + Future get levelProgress => + _analyticsController.levelProgress(_l2!.langCodeShort); + + Future?> 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 .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 - ? (activityState.value - as AsyncLoaded) - .value - : null; - - bool get _isComplete => _sessionLoader.value?.isComplete ?? false; - - ValueNotifier> get sessionState => - _sessionLoader.state; - - AnalyticsDataService get _analyticsService => - Matrix.of(context).analyticsDataService; - - LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; - - List filteredChoices( - MultipleChoicePracticeActivityModel activity, - ) { - final content = activity.multipleChoiceContent; - final choices = content.choices.toList(); - final answer = content.answers.first; - final filtered = []; - - final seenTexts = {}; - 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 _waitForAnalytics() async { - if (!_analyticsService.initCompleter.isCompleted) { - MatrixState.pangeaController.initControllers(); - await _analyticsService.initCompleter.future; - } - } - Future _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 _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 reloadSession() async { + void _playActivityAudio(MultipleChoicePracticeActivityModel activity) => + AnalyticsPracticeUiController.playTargetAudio( + activity, + widget.type, + _l2!.langCodeShort, + ); + + Future startSession() async { _clearState(); - _sessionLoader.reset(); - await _startSession(); - } + await _analyticsController.waitForAnalytics(); + await _sessionController.startSession(widget.type); + if (mounted) setState(() {}); - Future 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 _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 _continueSession() async { - if (_continuing) return; - _continuing = true; - enableChoicesNotifier.value = true; - showingAudioCompletion.value = false; - correctAnswersSelected.value = 0; + if (activityState.value + is AsyncLoading) { + return; + } + + _clearActivityState(loadingActivity: true); try { - if (activityState.value - is AsyncIdle) { - 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 _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 _fillActivityQueue(List requests) async { - for (final request in requests) { - final completer = Completer(); - _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 _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 _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 _fetchLemmaInfo( - String requestKey, - List choiceIds, - ) async { - final texts = {}; - final emojis = {}; - - 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 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 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 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?> getExampleMessage( - MessageActivityRequest activityRequest, - ) async { - final target = activityRequest.target; - final token = target.tokens.first; - final construct = target.targetTokenConstructID(token); + Future 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 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? getAudioExampleMessage() { - final activity = _currentActivity; - if (activity is VocabAudioPracticeActivityModel) { - return activity.exampleMessage.exampleMessage; - } - return null; - } - - Future 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); } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_controller.dart b/lib/pangea/analytics_practice/analytics_practice_session_controller.dart new file mode 100644 index 000000000..b86b031cd --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_session_controller.dart @@ -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 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 get activityRequests => + session?.activityRequests ?? []; + + List 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 uses) { + session?.submitAnswer(uses); + } + + Future 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 completeSession() async { + session?.finishSession(); + } + + Future _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 _fillActivityQueue( + List requests, + Future Function(MessageActivityRequest) onSkip, + Future Function(MultipleChoicePracticeActivityModel) onFetch, + ) async { + for (final request in requests) { + final completer = Completer(); + _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 _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 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; + } +} diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index 279bdf402..94fb1147a 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -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 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 uses) => state = state.copyWith(completedUses: [...state.completedUses, ...uses]); + void useHint() => state = state.copyWith(hintsUsed: state.hintsUsed + 1); + factory AnalyticsPracticeSessionModel.fromJson(Map 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) .map((e) => AnalyticsActivityTarget.fromJson(e)) .whereType() @@ -203,6 +242,7 @@ class AnalyticsPracticeSessionModel { Map 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 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? 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, ); diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 6df910d67..bcbe38a68 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -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; diff --git a/lib/pangea/analytics_practice/analytics_practice_ui_controller.dart b/lib/pangea/analytics_practice/analytics_practice_ui_controller.dart new file mode 100644 index 000000000..ace2ca432 --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_ui_controller.dart @@ -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)), + ); + } +} diff --git a/lib/pangea/analytics_practice/analytics_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart index 2a5077644..8c16a6d12 100644 --- a/lib/pangea/analytics_practice/analytics_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -1,36 +1,15 @@ 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'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pages/chat/events/audio_player.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; -import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart'; -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/analytics_practice/completed_activity_session_view.dart'; +import 'package:fluffychat/pangea/analytics_practice/ongoing_activity_session_view.dart'; import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart'; import 'package:fluffychat/pangea/analytics_practice/unsubscribed_practice_page.dart'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; -import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; -import 'package:fluffychat/pangea/common/widgets/pressable_button.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/layouts/max_width_body.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class AnalyticsPracticeView extends StatelessWidget { final AnalyticsPracticeState controller; @@ -39,6 +18,7 @@ class AnalyticsPracticeView extends StatelessWidget { @override Widget build(BuildContext context) { + final session = controller.session.session; const loading = Center( child: SizedBox( width: 24, @@ -53,30 +33,19 @@ class AnalyticsPracticeView extends StatelessWidget { children: [ Expanded( child: ValueListenableBuilder( - valueListenable: controller.progressNotifier, - builder: (context, progress, _) { - return AnimatedProgressBar( - height: 20.0, - widthPercent: progress, - barColor: Theme.of(context).colorScheme.primary, - ); - }, + valueListenable: controller.progress, + builder: (context, progress, _) => AnimatedProgressBar( + height: 20.0, + widthPercent: progress, + barColor: Theme.of(context).colorScheme.primary, + ), ), ), - //keep track of state to update timer - ValueListenableBuilder( - valueListenable: controller.sessionState, - builder: (context, state, _) { - if (state is AsyncLoaded) { - return PracticeTimerWidget( - key: ValueKey(state.value.startedAt), - initialSeconds: state.value.state.elapsedSeconds, - onTimeUpdate: controller.updateElapsedTime, - isRunning: !state.value.isComplete, - ); - } - return const SizedBox.shrink(); - }, + PracticeTimerWidget( + key: ValueKey(session?.startedAt ?? DateTime(0)), + initialSeconds: session?.state.elapsedSeconds ?? 0, + onTimeUpdate: controller.session.updateElapsedTime, + isRunning: session?.isComplete != true, ), ], ), @@ -86,1036 +55,31 @@ class AnalyticsPracticeView extends StatelessWidget { child: MaxWidthBody( withScrolling: false, showBorder: false, - child: ValueListenableBuilder( - valueListenable: controller.sessionState, - builder: (context, state, _) { - return switch (state) { - AsyncError(:final error) => - error is UnsubscribedException - ? const UnsubscribedPracticePage() - : ErrorIndicator( - message: error.toLocalizedString(context), - ), - AsyncLoaded(:final value) => - value.isComplete - ? CompletedActivitySessionView(state.value, controller) - : _AnalyticsActivityView(controller), - _ => loading, - }; - }, - ), - ), - ), - ); - } -} - -class _AnalyticsActivityView extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _AnalyticsActivityView(this.controller); - - @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 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: _HintsCounterBar(controller: controller), - ), - //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: ValueListenableBuilder( - valueListenable: controller.activityTarget, - builder: (context, target, _) { - if (target == null) return const SizedBox.shrink(); - - final isAudioActivity = - target.target.activityType == - ActivityTypeEnum.lemmaAudio; - final isVocabType = - controller.widget.type == ConstructTypeEnum.vocab; - - final token = target.target.tokens.first; - - return Column( - children: [ - Text( - isAudioActivity && isVocabType - ? L10n.of(context).selectAllWords - : target.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), - ), - ], - ); - }, - ), - ), - const SizedBox(height: 16.0), - Center( - child: _AnalyticsPracticeCenterContent(controller: controller), - ), - const SizedBox(height: 16.0), - 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), - _WrongAnswerFeedback(controller: controller), - ], - ), - ), - Container( - alignment: Alignment.bottomCenter, - child: _AudioContinueButton(controller: controller), - ), - ], - ); - } -} - -class _AnalyticsPracticeCenterContent extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _AnalyticsPracticeCenterContent({required this.controller}); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.activityTarget, - builder: (context, target, _) => switch (target?.target.activityType) { - null => const SizedBox(), - ActivityTypeEnum.grammarError => SingleChildScrollView( - child: ListenableBuilder( - listenable: Listenable.merge([ - controller.activityState, - controller.hintPressedNotifier, - ]), - builder: (context, _) { - final state = controller.activityState.value; - if (state is! AsyncLoaded) { - return const SizedBox(); - } - final activity = state.value; - if (activity is! GrammarErrorPracticeActivityModel) { - return const SizedBox(); - } - return _ErrorBlankWidget( - key: ValueKey( - '${activity.eventID}_${activity.errorOffset}_${activity.errorLength}', - ), - activity: activity, - showTranslation: controller.hintPressedNotifier.value, - ); - }, - ), - ), - ActivityTypeEnum.grammarCategory => Center( - child: _ExampleMessageWidget(controller.getExampleMessage(target!)), - ), - ActivityTypeEnum.lemmaAudio => ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, _) => switch (state) { - AsyncLoaded( - value: final VocabAudioPracticeActivityModel activity, - ) => - 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: controller.getAudioFile(activity.eventId)!, - autoplay: true, - ), - ), - ), - _ => const SizedBox(height: 100.0), - }, - ), - _ => SizedBox( - height: 100.0, - child: Center( - child: _ExampleMessageWidget(controller.getExampleMessage(target!)), - ), - ), - }, - ); - } -} - -class _AudioCompletionWidget extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _AudioCompletionWidget({super.key, required this.controller}); - - String _extractTextFromSpans(List spans) { - final buffer = StringBuffer(); - for (final span in spans) { - if (span is TextSpan && span.text != null) { - buffer.write(span.text); - } - } - return buffer.toString(); - } - - @override - Widget build(BuildContext context) { - final exampleMessage = controller.getAudioExampleMessage(); - - if (exampleMessage == null || 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: [ - ValueListenableBuilder( - valueListenable: controller.hintPressedNotifier, - builder: (context, showPhonetics, _) => AnimatedSize( - duration: FluffyThemes.animationDuration, - alignment: Alignment.topCenter, - child: showPhonetics - ? Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: PhoneticTranscriptionWidget( - text: exampleText, - 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(controller: controller), - ), - ], - ), - ), - ); - } -} - -/// Widget to show translation for audio completion message -class _AudioCompletionTranslation extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _AudioCompletionTranslation({required this.controller}); - - @override - Widget build(BuildContext context) { - final state = controller.activityState.value; - if (state is! AsyncLoaded) { - return const SizedBox.shrink(); - } - - final activity = state.value; - if (activity is! VocabAudioPracticeActivityModel) { - return const SizedBox.shrink(); - } - - final translation = controller.getAudioTranslation(activity.eventId); - if (translation == null) { - return const SizedBox.shrink(); - } - - return Text( - translation, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onPrimaryFixed.withValues(alpha: 0.8), - fontSize: - (AppSettings.fontSizeFactor.value * AppConfig.messageFontSize) * - 0.9, - fontStyle: FontStyle.italic, - ), - ); - } -} - -class _ExampleMessageWidget extends StatelessWidget { - final Future?> future; - - const _ExampleMessageWidget(this.future); - - @override - Widget build(BuildContext context) { - return FutureBuilder?>( - 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!, - ), - ), - ); - }, - ); - } -} - -class _HintsCounterBar extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _HintsCounterBar({required this.controller}); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.hintsUsedNotifier, - builder: (context, hintsUsed, _) { - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate( - AnalyticsPracticeState.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, - ), - ), - ), - ), - ); - }, - ); - } -} - -class _HintSection extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _HintSection({required this.controller}); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([ - controller.activityState, - controller.hintPressedNotifier, - controller.hintsUsedNotifier, - ]), - builder: (context, _) { - final state = controller.activityState.value; - if (state is! AsyncLoaded) { - return const SizedBox.shrink(); - } - - final activity = state.value; - final hintPressed = controller.hintPressedNotifier.value; - final hintsUsed = controller.hintsUsedNotifier.value; - final maxHintsReached = hintsUsed >= AnalyticsPracticeState.maxHints; - - return ConstrainedBox( - 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, - ); + final error = controller.session.sessionError; + if (error != null) { + return error is UnsubscribedException + ? const UnsubscribedPracticePage() + : ErrorIndicator(message: error.toLocalizedString(context)); } - // For grammar category: fade out button and show hint content - if (activity is MorphPracticeActivityModel) { - return AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: hintPressed - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: HintButton( - icon: Icons.lightbulb_outline, - onPressed: maxHintsReached - ? () {} - : controller.onHintPressed, - depressed: maxHintsReached, - ), - secondChild: MorphMeaningWidget( - feature: activity.morphFeature, - tag: activity.multipleChoiceContent.answers.first, - ), - ); + final session = controller.session.session; + if (session != null) { + return session.isComplete + ? CompletedActivitySessionView( + session: session, + launchSession: controller.startSession, + levelProgress: controller.levelProgress, + ) + : OngoingActivitySessionView(controller); } - // For grammar error: button stays pressed, hint shows in ErrorBlankWidget - return HintButton( - icon: Icons.lightbulb_outline, - onPressed: (hintPressed || maxHintsReached) - ? () {} - : controller.onHintPressed, - depressed: hintPressed || maxHintsReached, - ); + return loading; }, ), - ); - }, - ); - } -} - -class _WrongAnswerFeedback extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _WrongAnswerFeedback({required this.controller}); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([ - controller.activityState, - controller.selectedMorphChoice, - ]), - builder: (context, _) { - final activityState = controller.activityState.value; - final selectedChoice = controller.selectedMorphChoice.value; - - if (activityState - is! AsyncLoaded || - selectedChoice == null) { - return const SizedBox.shrink(); - } - - final activity = activityState.value; - 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, - ), - ); - }, - ); - } -} - -class _ErrorBlankWidget extends StatelessWidget { - final GrammarErrorPracticeActivityModel activity; - final bool showTranslation; - - const _ErrorBlankWidget({ - 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(), - ), - ], ), ); } } - -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), - ], - ), - ); - } -} - -class _ActivityChoicesWidget extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _ActivityChoicesWidget(this.controller); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, _) { - return switch (state) { - AsyncLoading() => const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator.adaptive(), - ), - ), - AsyncError(:final error) => - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - //allow try to reload activity in case of error - ErrorIndicator(message: error.toString()), - const SizedBox(height: 16), - TextButton.icon( - onPressed: controller.reloadCurrentActivity, - icon: const Icon(Icons.refresh), - label: Text(L10n.of(context).tryAgain), - ), - ], - ), - AsyncLoaded(:final value) => - ValueListenableBuilder( - valueListenable: controller.enableChoicesNotifier, - builder: (context, enabled, _) { - final choices = controller.filteredChoices(value); - final isAudioActivity = - value.activityType == ActivityTypeEnum.lemmaAudio; - - if (isAudioActivity) { - // For audio activities, use AnimatedSwitcher to fade between choices and example message - return ValueListenableBuilder( - valueListenable: controller.showingAudioCompletion, - builder: (context, showingCompletion, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 500), - layoutBuilder: (currentChild, previousChildren) { - return Stack( - alignment: Alignment.topCenter, - children: [ - ...previousChildren, - ?currentChild, - ], - ); - }, - child: showingCompletion - ? _AudioCompletionWidget( - key: const ValueKey('completion'), - controller: controller, - ) - : 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) => _ChoiceCard( - activity: value, - targetId: controller.choiceTargetId( - choice.choiceId, - ), - choiceId: choice.choiceId, - onPressed: () => controller - .onSelectChoice(choice.choiceId), - cardHeight: 48.0, - controller: controller, - choiceText: choice.choiceText, - choiceEmoji: choice.choiceEmoji, - enabled: enabled, - shrinkWrap: true, - ), - ) - .toList(), - ), - ), - ); - }, - ); - } - - return Column( - spacing: 8.0, - mainAxisAlignment: MainAxisAlignment.center, - children: choices - .map( - (choice) => _ChoiceCard( - activity: value, - targetId: controller.choiceTargetId(choice.choiceId), - choiceId: choice.choiceId, - onPressed: () => - controller.onSelectChoice(choice.choiceId), - cardHeight: 60.0, - controller: controller, - choiceText: choice.choiceText, - choiceEmoji: choice.choiceEmoji, - enabled: enabled, - ), - ) - .toList(), - ); - }, - ), - _ => Container( - constraints: const BoxConstraints(maxHeight: 400.0), - child: const Center(child: CircularProgressIndicator.adaptive()), - ), - }; - }, - ); - } -} - -class _AudioContinueButton extends StatelessWidget { - final AnalyticsPracticeState controller; - - const _AudioContinueButton({required this.controller}); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.activityState, - builder: (context, state, _) { - // Only show for audio activities - if (state is! AsyncLoaded) { - return const SizedBox.shrink(); - } - - final activity = state.value; - if (activity.activityType != ActivityTypeEnum.lemmaAudio) { - return const SizedBox.shrink(); - } - - 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: 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), - ), - ), - ), - ), - ), - ), - ), - ), - // 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), - ), - ), - ), - ], - ), - ); - }, - ); - }, - ); - } -} - -class _ChoiceCard extends StatelessWidget { - final MultipleChoicePracticeActivityModel activity; - final String choiceId; - final String targetId; - final VoidCallback onPressed; - final double cardHeight; - final AnalyticsPracticeState controller; - - final String choiceText; - final String? choiceEmoji; - final bool enabled; - final bool shrinkWrap; - - const _ChoiceCard({ - required this.activity, - required this.choiceId, - required this.targetId, - required this.onPressed, - required this.cardHeight, - required this.controller, - required this.choiceText, - required this.choiceEmoji, - 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 ValueListenableBuilder( - valueListenable: controller.hintPressedNotifier, - builder: (context, showPhonetics, _) => AudioChoiceCard( - key: ValueKey( - '${constructId.string}_${activityType.name}_audio_$choiceId', - ), - choiceId: choiceId, - targetId: targetId, - displayText: choiceText, - textLanguage: MatrixState.pangeaController.userController.userL2!, - onPressed: onPressed, - isCorrect: isCorrect, - isEnabled: enabled, - showPhoneticTranscription: showPhonetics, - ), - ); - - 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), - ); - } - } -} diff --git a/lib/pangea/analytics_practice/audio_activity_continue_button_widget.dart b/lib/pangea/analytics_practice/audio_activity_continue_button_widget.dart new file mode 100644 index 000000000..9857d773d --- /dev/null +++ b/lib/pangea/analytics_practice/audio_activity_continue_button_widget.dart @@ -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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart index 5193e5587..744d1fd32 100644 --- a/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart @@ -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: [ diff --git a/lib/pangea/analytics_practice/completed_activity_session_view.dart b/lib/pangea/analytics_practice/completed_activity_session_view.dart index 9d351110d..00240e532 100644 --- a/lib/pangea/analytics_practice/completed_activity_session_view.dart +++ b/lib/pangea/analytics_practice/completed_activity_session_view.dart @@ -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 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)], diff --git a/lib/pangea/analytics_practice/grammar_error_example_widget.dart b/lib/pangea/analytics_practice/grammar_error_example_widget.dart new file mode 100644 index 000000000..67049ba35 --- /dev/null +++ b/lib/pangea/analytics_practice/grammar_error_example_widget.dart @@ -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(), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_practice/ongoing_activity_session_view.dart b/lib/pangea/analytics_practice/ongoing_activity_session_view.dart new file mode 100644 index 000000000..9da9ac173 --- /dev/null +++ b/lib/pangea/analytics_practice/ongoing_activity_session_view.dart @@ -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? 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, + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 0fe576a90..6cf3ae0a9 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -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 toJson() { return { 'user_l1': userL1, diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index 4b4bc8de5..22b20a5e7 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -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;