From b698e2e84feb5a3a097957bcc8fcd74dbee6d3ea Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 14 Jan 2026 16:06:22 -0500 Subject: [PATCH] feat: grammar practice --- lib/config/routes.dart | 18 +- lib/l10n/intl_en.arb | 6 +- .../analytics_details_popup.dart | 116 ++++++----- .../morph_analytics_list_view.dart | 1 + .../analytics_misc/construct_type_enum.dart | 38 ++-- .../construct_use_type_enum.dart | 18 ++ .../analytics_misc/constructs_model.dart | 2 +- .../lemma_emoji_setter_mixin.dart | 1 - .../analytics_practice_constants.dart} | 2 +- .../analytics_practice_page.dart} | 88 +++++---- .../analytics_practice_session_model.dart} | 25 +-- .../analytics_practice_session_repo.dart | 182 ++++++++++++++++++ .../analytics_practice_view.dart} | 70 +++---- .../choice_cards/audio_choice_card.dart | 2 +- .../choice_cards/game_choice_card.dart | 0 .../choice_cards/meaning_choice_card.dart | 2 +- .../completed_activity_session_view.dart | 16 +- .../morph_category_activity_generator.dart | 65 +++++++ .../percent_marker_bar.dart | 0 .../practice_timer_widget.dart} | 10 +- .../stat_card.dart | 0 .../vocab_audio_activity_generator.dart | 0 .../vocab_meaning_activity_generator.dart | 0 .../activity_type_enum.dart | 56 +++++- .../message_activity_request.dart | 9 + .../practice_activity_model.dart | 40 ++++ .../practice_generation_repo.dart | 7 +- .../vocab_practice_session_repo.dart | 102 ---------- 28 files changed, 573 insertions(+), 303 deletions(-) rename lib/pangea/{vocab_practice/vocab_practice_constants.dart => analytics_practice/analytics_practice_constants.dart} (70%) rename lib/pangea/{vocab_practice/vocab_practice_page.dart => analytics_practice/analytics_practice_page.dart} (84%) rename lib/pangea/{vocab_practice/vocab_practice_session_model.dart => analytics_practice/analytics_practice_session_model.dart} (87%) create mode 100644 lib/pangea/analytics_practice/analytics_practice_session_repo.dart rename lib/pangea/{vocab_practice/vocab_practice_view.dart => analytics_practice/analytics_practice_view.dart} (83%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/audio_choice_card.dart (93%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/game_choice_card.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/choice_cards/meaning_choice_card.dart (96%) rename lib/pangea/{vocab_practice => analytics_practice}/completed_activity_session_view.dart (93%) create mode 100644 lib/pangea/analytics_practice/morph_category_activity_generator.dart rename lib/pangea/{vocab_practice => analytics_practice}/percent_marker_bar.dart (100%) rename lib/pangea/{vocab_practice/vocab_timer_widget.dart => analytics_practice/practice_timer_widget.dart} (87%) rename lib/pangea/{vocab_practice => analytics_practice}/stat_card.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_audio_activity_generator.dart (100%) rename lib/pangea/{vocab_practice => analytics_practice}/vocab_meaning_activity_generator.dart (100%) delete mode 100644 lib/pangea/vocab_practice/vocab_practice_session_repo.dart diff --git a/lib/config/routes.dart b/lib/config/routes.dart index a2487a95a..2530b02c3 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -38,6 +38,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.dart' import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_page/activity_archive.dart'; import 'package:fluffychat/pangea/analytics_page/empty_analytics_page.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart'; import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_content.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/chat_settings/pages/edit_course.dart'; @@ -59,7 +60,6 @@ import 'package:fluffychat/pangea/login/pages/signup.dart'; import 'package:fluffychat/pangea/space_analytics/space_analytics.dart'; import 'package:fluffychat/pangea/spaces/space_constants.dart'; import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; import 'package:fluffychat/widgets/config_viewer.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; @@ -542,6 +542,18 @@ abstract class AppRoutes { ), redirect: loggedOutRedirect, routes: [ + GoRoute( + path: 'practice', + pageBuilder: (context, state) { + return defaultPageBuilder( + context, + state, + const AnalyticsPractice( + type: ConstructTypeEnum.morph, + ), + ); + }, + ), GoRoute( path: ':construct', pageBuilder: (context, state) { @@ -580,7 +592,9 @@ abstract class AppRoutes { return defaultPageBuilder( context, state, - const VocabPractice(), + const AnalyticsPractice( + type: ConstructTypeEnum.vocab, + ), ); }, ), diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 487a6df31..cd54d8644 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5046,5 +5046,9 @@ "voice": "Voice", "youLeftTheChat": "🚪 You left the chat", "downloadInitiated": "Download initiated", - "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site." + "webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site.", + "practiceGrammar": "Practice grammar", + "notEnoughToPractice": "Send more messages to unlock practice", + "constructUseCorGCDesc": "Correct grammar category practice", + "constructUseIncGCDesc": "Incorrect grammar category practice" } diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index c816dfb80..4a9f32681 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -183,72 +183,66 @@ class ConstructAnalyticsViewState extends State { ), ), floatingActionButton: - widget.view == ConstructTypeEnum.vocab && widget.construct == null - ? _buildVocabPracticeButton(context) - : null, + widget.construct == null ? _PracticeButton(view: widget.view) : null, ); } } -Widget _buildVocabPracticeButton(BuildContext context) { - // Check if analytics is loaded first - if (MatrixState - .pangeaController.matrixState.analyticsDataService.isInitializing) { +class _PracticeButton extends StatelessWidget { + final ConstructTypeEnum view; + const _PracticeButton({required this.view}); + + void _showSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final analyticsService = Matrix.of(context).analyticsDataService; + if (analyticsService.isInitializing) { + return FloatingActionButton.extended( + onPressed: () => _showSnackbar( + context, + L10n.of(context).loadingPleaseWait, + ), + label: Text(view.practiceButtonText(context)), + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ); + } + + final count = analyticsService.numConstructs(view); + final enabled = count >= 10; + return FloatingActionButton.extended( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Loading vocabulary data...', - ), - behavior: SnackBarBehavior.floating, - ), - ); - }, - label: Text(L10n.of(context).practiceVocab), - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + onPressed: enabled + ? () => context.go("/rooms/analytics/${view.name}/practice") + : () => _showSnackbar( + context, + L10n.of(context).notEnoughToPractice, + ), + backgroundColor: + enabled ? null : Theme.of(context).colorScheme.surfaceContainer, + foregroundColor: enabled + ? null + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!enabled) ...[ + const Icon(Icons.lock_outline, size: 18), + const SizedBox(width: 4), + ], + Text(view.practiceButtonText(context)), + ], + ), ); } - - final vocabCount = MatrixState - .pangeaController.matrixState.analyticsDataService - .numConstructs(ConstructTypeEnum.vocab); - final hasEnoughVocab = vocabCount >= 10; - - return FloatingActionButton.extended( - onPressed: hasEnoughVocab - ? () { - context.go( - "/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice", - ); - } - : () { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - L10n.of(context).mustHave10Words, - ), - behavior: SnackBarBehavior.floating, - ), - ); - }, - backgroundColor: - hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer, - foregroundColor: hasEnoughVocab - ? null - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!hasEnoughVocab) ...[ - const Icon(Icons.lock_outline, size: 18), - const SizedBox(width: 4), - ], - Text(L10n.of(context).practiceVocab), - ], - ), - ); } diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index f923391e0..9be8139e0 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -75,6 +75,7 @@ class MorphAnalyticsListView extends StatelessWidget { childCount: controller.features.length, ), ), + const SliverToBoxAdapter(child: SizedBox(height: 75.0)), ], ), ), diff --git a/lib/pangea/analytics_misc/construct_type_enum.dart b/lib/pangea/analytics_misc/construct_type_enum.dart index 910d3cfbe..ad7e3f0a2 100644 --- a/lib/pangea/analytics_misc/construct_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_type_enum.dart @@ -4,20 +4,15 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; enum ConstructTypeEnum { /// for vocabulary words vocab, /// for morphs, actually called "Grammar" in the UI... :P - morph, -} + morph; -extension ConstructExtension on ConstructTypeEnum { String get string { switch (this) { case ConstructTypeEnum.vocab: @@ -37,25 +32,6 @@ extension ConstructExtension on ConstructTypeEnum { } } - int get maxXPPerLemma { - switch (this) { - case ConstructTypeEnum.vocab: - return AnalyticsConstants.vocabUseMaxXP; - case ConstructTypeEnum.morph: - return AnalyticsConstants.morphUseMaxXP; - } - } - - String? getDisplayCopy(String category, BuildContext context) { - switch (this) { - case ConstructTypeEnum.morph: - return MorphFeaturesEnumExtension.fromString(category) - .getDisplayCopy(context); - case ConstructTypeEnum.vocab: - return getVocabCategoryName(category, context); - } - } - ProgressIndicatorEnum get indicator { switch (this) { case ConstructTypeEnum.morph: @@ -64,9 +40,17 @@ extension ConstructExtension on ConstructTypeEnum { return ProgressIndicatorEnum.wordsUsed; } } -} -class ConstructTypeUtil { + String practiceButtonText(BuildContext context) { + final l10n = L10n.of(context); + switch (this) { + case ConstructTypeEnum.vocab: + return l10n.practiceVocab; + case ConstructTypeEnum.morph: + return l10n.practiceGrammar; + } + } + static ConstructTypeEnum fromString(String? string) { switch (string) { case 'v': diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index 9dd3ba4c0..028759fe4 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -82,6 +82,10 @@ enum ConstructUseTypeEnum { // vocab lemma audio activity corLA, incLA, + + // grammar category activity + corGC, + incGC, } extension ConstructUseTypeExtension on ConstructUseTypeEnum { @@ -163,6 +167,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseCorLADesc; case ConstructUseTypeEnum.incLA: return L10n.of(context).constructUseIncLADesc; + case ConstructUseTypeEnum.corGC: + return L10n.of(context).constructUseCorGCDesc; + case ConstructUseTypeEnum.incGC: + return L10n.of(context).constructUseIncGCDesc; } } @@ -203,6 +211,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return ActivityTypeEnum.morphId.icon; case ConstructUseTypeEnum.em: return ActivityTypeEnum.emoji.icon; @@ -235,6 +245,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: return 5; case ConstructUseTypeEnum.pvm: @@ -275,6 +286,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: return -1; case ConstructUseTypeEnum.incPA: @@ -326,6 +338,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corLA: case ConstructUseTypeEnum.incLA: case ConstructUseTypeEnum.bonus: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return false; } } @@ -369,6 +383,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.click: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.incLM: + case ConstructUseTypeEnum.corGC: + case ConstructUseTypeEnum.incGC: return LearningSkillsEnum.reading; case ConstructUseTypeEnum.pvm: return LearningSkillsEnum.speaking; @@ -398,6 +414,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.corMM: case ConstructUseTypeEnum.corLM: case ConstructUseTypeEnum.corLA: + case ConstructUseTypeEnum.corGC: return SpaceAnalyticsSummaryEnum.numChoicesCorrect; case ConstructUseTypeEnum.incIt: @@ -410,6 +427,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incMM: case ConstructUseTypeEnum.incLM: case ConstructUseTypeEnum.incLA: + case ConstructUseTypeEnum.incGC: return SpaceAnalyticsSummaryEnum.numChoicesIncorrect; case ConstructUseTypeEnum.ignIt: diff --git a/lib/pangea/analytics_misc/constructs_model.dart b/lib/pangea/analytics_misc/constructs_model.dart index 5f0ee420c..cb053585b 100644 --- a/lib/pangea/analytics_misc/constructs_model.dart +++ b/lib/pangea/analytics_misc/constructs_model.dart @@ -117,7 +117,7 @@ class OneConstructUse { debugger(when: kDebugMode && json['constructType'] == null); final ConstructTypeEnum constructType = json['constructType'] != null - ? ConstructTypeUtil.fromString(json['constructType']) + ? ConstructTypeEnum.fromString(json['constructType']) : ConstructTypeEnum.vocab; final useType = ConstructUseTypeUtil.fromString(json['useType']); diff --git a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart index 65f4a7a09..0719943e3 100644 --- a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart +++ b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_tile.dart'; import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.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/constructs/construct_identifier.dart'; diff --git a/lib/pangea/vocab_practice/vocab_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart similarity index 70% rename from lib/pangea/vocab_practice/vocab_practice_constants.dart rename to lib/pangea/analytics_practice/analytics_practice_constants.dart index 70bcaf6bc..3d049b597 100644 --- a/lib/pangea/vocab_practice/vocab_practice_constants.dart +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -1,4 +1,4 @@ -class VocabPracticeConstants { +class AnalyticsPracticeConstants { static const int timeForBonus = 60; static const int practiceGroupSize = 10; } diff --git a/lib/pangea/vocab_practice/vocab_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart similarity index 84% rename from lib/pangea/vocab_practice/vocab_practice_page.dart rename to lib/pangea/analytics_practice/analytics_practice_page.dart index fe379e494..709682e6a 100644 --- a/lib/pangea/vocab_practice/vocab_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -13,6 +13,9 @@ 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_session_model.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.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/lemmas/lemma_info_repo.dart'; @@ -22,47 +25,52 @@ import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_m import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class VocabPracticeChoice { +class PracticeChoice { final String choiceId; final String choiceText; final String? choiceEmoji; - const VocabPracticeChoice({ + const PracticeChoice({ required this.choiceId, required this.choiceText, this.choiceEmoji, }); } -class SessionLoader extends AsyncLoader { - @override - Future fetch() => VocabPracticeSessionRepo.get(); -} - -class VocabPractice extends StatefulWidget { - const VocabPractice({super.key}); +class SessionLoader extends AsyncLoader { + final ConstructTypeEnum type; + SessionLoader({required this.type}); @override - VocabPracticeState createState() => VocabPracticeState(); + Future fetch() => + AnalyticsPracticeSessionRepo.get(type); } -class VocabPracticeState extends State with AnalyticsUpdater { - final SessionLoader _sessionLoader = SessionLoader(); +class AnalyticsPractice extends StatefulWidget { + final ConstructTypeEnum type; + const AnalyticsPractice({ + super.key, + required this.type, + }); + + @override + AnalyticsPracticeState createState() => AnalyticsPracticeState(); +} + +class AnalyticsPracticeState extends State + with AnalyticsUpdater { + late final SessionLoader _sessionLoader; final ValueNotifier> activityState = ValueNotifier(const AsyncState.idle()); - final Queue>> - _queue = Queue(); + final Queue>> _queue = + Queue(); - final ValueNotifier activityConstructId = - ValueNotifier(null); + final ValueNotifier activityText = ValueNotifier(null); final ValueNotifier progressNotifier = ValueNotifier(0.0); @@ -74,6 +82,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { @override void initState() { super.initState(); + _sessionLoader = SessionLoader(type: widget.type); _startSession(); _languageStreamSubscription = MatrixState .pangeaController.userController.languageStream.stream @@ -84,13 +93,13 @@ class VocabPracticeState extends State with AnalyticsUpdater { void dispose() { _languageStreamSubscription?.cancel(); if (_isComplete) { - VocabPracticeSessionRepo.clear(); + AnalyticsPracticeSessionRepo.clear(); } else { _saveSession(); } _sessionLoader.dispose(); activityState.dispose(); - activityConstructId.dispose(); + activityText.dispose(); progressNotifier.dispose(); super.dispose(); } @@ -102,19 +111,19 @@ class VocabPracticeState extends State with AnalyticsUpdater { bool get _isComplete => _sessionLoader.value?.isComplete ?? false; - ValueNotifier> get sessionState => + ValueNotifier> get sessionState => _sessionLoader.state; AnalyticsDataService get _analyticsService => Matrix.of(context).analyticsDataService; - List filteredChoices( + List filteredChoices( PracticeTarget target, MultipleChoiceActivity activity, ) { final choices = activity.choices.toList(); final answer = activity.answers.first; - final filtered = []; + final filtered = []; final seenTexts = {}; for (final id in choices) { @@ -129,7 +138,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { (choice) => choice.choiceText == text, ); if (index != -1) { - filtered[index] = VocabPracticeChoice( + filtered[index] = PracticeChoice( choiceId: id, choiceText: text, choiceEmoji: getChoiceEmoji(target, id), @@ -140,7 +149,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { seenTexts.add(text); filtered.add( - VocabPracticeChoice( + PracticeChoice( choiceId: id, choiceText: text, choiceEmoji: getChoiceEmoji(target, id), @@ -164,11 +173,11 @@ class VocabPracticeState extends State with AnalyticsUpdater { _choiceEmojis[target]?[choiceId]; String choiceTargetId(String choiceId) => - 'vocab-choice-card-${choiceId.replaceAll(' ', '_')}'; + '${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}'; void _resetActivityState() { activityState.value = const AsyncState.loading(); - activityConstructId.value = null; + activityText.value = null; } void _resetSessionState() { @@ -187,7 +196,10 @@ class VocabPracticeState extends State with AnalyticsUpdater { Future _saveSession() async { if (_sessionLoader.isLoaded) { - await VocabPracticeSessionRepo.update(_sessionLoader.value!); + await AnalyticsPracticeSessionRepo.update( + widget.type, + _sessionLoader.value!, + ); } } @@ -225,7 +237,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { Future reloadSession() async { _resetActivityState(); _resetSessionState(); - await VocabPracticeSessionRepo.clear(); + await AnalyticsPracticeSessionRepo.clear(); _sessionLoader.reset(); await _startSession(); } @@ -253,7 +265,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { } else { activityState.value = const AsyncState.loading(); final nextActivityCompleter = _queue.removeFirst(); - activityConstructId.value = nextActivityCompleter.key; + activityText.value = nextActivityCompleter.key; final activity = await nextActivityCompleter.value.future; activityState.value = AsyncState.loaded(activity); } @@ -277,7 +289,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { final res = await _fetchActivity(req); if (!mounted) return; - activityConstructId.value = req.targetTokens.first.vocabConstructID; + activityText.value = req.activityText; activityState.value = AsyncState.loaded(res); } catch (e) { if (!mounted) return; @@ -293,7 +305,7 @@ class VocabPracticeState extends State with AnalyticsUpdater { final completer = Completer(); _queue.add( MapEntry( - request.targetTokens.first.vocabConstructID, + request.activityText, completer, ), ); @@ -375,14 +387,14 @@ class VocabPracticeState extends State with AnalyticsUpdater { final use = OneConstructUse( useType: useType, - constructType: ConstructTypeEnum.vocab, + constructType: widget.type, metadata: ConstructUseMetaData( roomId: null, timeStamp: DateTime.now(), ), - category: activity.targetTokens.first.pos, - lemma: activity.targetTokens.first.lemma.text, - form: activity.targetTokens.first.lemma.text, + category: activity.useCategory, + lemma: activity.useLemma, + form: activity.useForm, xp: useType.pointValue, ); @@ -417,5 +429,5 @@ class VocabPracticeState extends State with AnalyticsUpdater { _analyticsService.derivedData; @override - Widget build(BuildContext context) => VocabPracticeView(this); + Widget build(BuildContext context) => AnalyticsPracticeView(this); } diff --git a/lib/pangea/vocab_practice/vocab_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart similarity index 87% rename from lib/pangea/vocab_practice/vocab_practice_session_model.dart rename to lib/pangea/analytics_practice/analytics_practice_session_model.dart index d0437ebe2..9c27769e9 100644 --- a/lib/pangea/vocab_practice/vocab_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -2,12 +2,11 @@ import 'dart:math'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart'; import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; -class VocabPracticeSessionModel { +class AnalyticsPracticeSessionModel { final DateTime startedAt; final List practiceTargets; final String userL1; @@ -15,22 +14,16 @@ class VocabPracticeSessionModel { VocabPracticeSessionState state; - VocabPracticeSessionModel({ + AnalyticsPracticeSessionModel({ required this.startedAt, required this.practiceTargets, required this.userL1, required this.userL2, VocabPracticeSessionState? state, - }) : assert( - practiceTargets.every( - (t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio} - .contains(t.activityType), - ), - ), - state = state ?? const VocabPracticeSessionState(); + }) : state = state ?? const VocabPracticeSessionState(); int get _availableActivities => min( - VocabPracticeConstants.practiceGroupSize, + AnalyticsPracticeConstants.practiceGroupSize, practiceTargets.length, ); @@ -47,7 +40,7 @@ class VocabPracticeSessionModel { activityQualityFeedback: null, targetTokens: target.tokens, targetType: target.activityType, - targetMorphFeature: null, + targetMorphFeature: target.morphFeature, ); }).toList(); } @@ -64,8 +57,8 @@ class VocabPracticeSessionModel { completedUses: [...state.completedUses, use], ); - factory VocabPracticeSessionModel.fromJson(Map json) { - return VocabPracticeSessionModel( + factory AnalyticsPracticeSessionModel.fromJson(Map json) { + return AnalyticsPracticeSessionModel( startedAt: DateTime.parse(json['startedAt'] as String), practiceTargets: (json['practiceTargets'] as List) .map((e) => PracticeTarget.fromJson(e)) @@ -115,7 +108,7 @@ class VocabPracticeSessionState { bool get _giveAccuracyBonus => accuracy >= 100.0; bool get _giveTimeBonus => - elapsedSeconds <= VocabPracticeConstants.timeForBonus; + elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus; int get bonusXP => accuracyBonusXP + timeBonusXP; diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart new file mode 100644 index 000000000..ecc97ef2a --- /dev/null +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -0,0 +1,182 @@ +import 'dart:math'; + +import 'package:get_storage/get_storage.dart'; + +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.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/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; +import 'package:fluffychat/pangea/lemmas/lemma.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_target.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnalyticsPracticeSessionRepo { + static final GetStorage _storage = GetStorage('practice_session'); + + static Future get( + ConstructTypeEnum type, + ) async { + final cached = _getCached(type); + if (cached != null) { + return cached; + } + + final r = Random(); + final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type); + + final types = List.generate( + AnalyticsPracticeConstants.practiceGroupSize, + (_) => activityTypes[r.nextInt(activityTypes.length)], + ); + + final List targets = []; + + if (type == ConstructTypeEnum.vocab) { + final constructs = await _fetchVocab(); + final targetCount = min(constructs.length, types.length); + targets.addAll([ + for (var i = 0; i < targetCount; i++) + PracticeTarget( + tokens: [constructs[i].asToken], + activityType: types[i], + ), + ]); + } else { + final morphs = await _fetchMorphs(); + targets.addAll([ + for (final entry in morphs.entries) + PracticeTarget( + tokens: [entry.key], + activityType: types[targets.length], + morphFeature: entry.value, + ), + ]); + } + + final session = AnalyticsPracticeSessionModel( + userL1: MatrixState.pangeaController.userController.userL1!.langCode, + userL2: MatrixState.pangeaController.userController.userL2!.langCode, + startedAt: DateTime.now(), + practiceTargets: targets, + ); + await _setCached(type, session); + return session; + } + + static Future update( + ConstructTypeEnum type, + AnalyticsPracticeSessionModel session, + ) => + _setCached(type, session); + + static Future clear() => _storage.erase(); + + static Future> _fetchVocab() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.vocab) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + return constructs + .where((construct) => construct.lemma.isNotEmpty) + .take(AnalyticsPracticeConstants.practiceGroupSize) + .map((construct) => construct.id) + .toList(); + } + + static Future> _fetchMorphs() async { + final constructs = await MatrixState + .pangeaController.matrixState.analyticsDataService + .getAggregatedConstructs(ConstructTypeEnum.morph) + .then((map) => map.values.toList()); + + // sort by last used descending, nulls first + constructs.sort((a, b) { + final dateA = a.lastUsed; + final dateB = b.lastUsed; + if (dateA == null && dateB == null) return 0; + if (dateA == null) return -1; + if (dateB == null) return 1; + return dateA.compareTo(dateB); + }); + + final targets = {}; + final Set seenForms = {}; + + for (final entry in constructs) { + if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + break; + } + + final feature = MorphFeaturesEnumExtension.fromString(entry.id.category); + if (feature == MorphFeaturesEnum.Unknown) { + continue; + } + + for (final use in entry.cappedUses) { + if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + break; + } + + final form = use.form; + if (seenForms.contains(form) || form == null) { + continue; + } + + seenForms.add(form); + final token = PangeaToken( + lemma: Lemma( + text: form, + saveVocab: true, + form: form, + ), + text: PangeaTokenText.fromString(form), + pos: 'other', + morph: {feature: use.lemma}, + ); + targets[token] = feature; + break; + } + } + + return targets; + } + + static AnalyticsPracticeSessionModel? _getCached( + ConstructTypeEnum type, + ) { + try { + final entry = _storage.read(type.name); + if (entry == null) return null; + final json = entry as Map; + return AnalyticsPracticeSessionModel.fromJson(json); + } catch (e) { + _storage.remove(type.name); + return null; + } + } + + static Future _setCached( + ConstructTypeEnum type, + AnalyticsPracticeSessionModel session, + ) async { + await _storage.write( + type.name, + session.toJson(), + ); + } +} diff --git a/lib/pangea/vocab_practice/vocab_practice_view.dart b/lib/pangea/analytics_practice/analytics_practice_view.dart similarity index 83% rename from lib/pangea/vocab_practice/vocab_practice_view.dart rename to lib/pangea/analytics_practice/analytics_practice_view.dart index a196e4186..1e6139c06 100644 --- a/lib/pangea/vocab_practice/vocab_practice_view.dart +++ b/lib/pangea/analytics_practice/analytics_practice_view.dart @@ -2,6 +2,13 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.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/meaning_choice_card.dart'; +import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart'; +import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; @@ -9,19 +16,12 @@ import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.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/vocab_practice/choice_cards/audio_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; -class VocabPracticeView extends StatelessWidget { - final VocabPracticeState controller; +class AnalyticsPracticeView extends StatelessWidget { + final AnalyticsPracticeState controller; - const VocabPracticeView(this.controller, {super.key}); + const AnalyticsPracticeView(this.controller, {super.key}); @override Widget build(BuildContext context) { @@ -53,8 +53,8 @@ class VocabPracticeView extends StatelessWidget { ValueListenableBuilder( valueListenable: controller.sessionState, builder: (context, state, __) { - if (state is AsyncLoaded) { - return VocabTimerWidget( + if (state is AsyncLoaded) { + return PracticeTimerWidget( key: ValueKey(state.value.startedAt), initialSeconds: state.value.state.elapsedSeconds, onTimeUpdate: controller.updateElapsedTime, @@ -78,12 +78,12 @@ class VocabPracticeView extends StatelessWidget { valueListenable: controller.sessionState, builder: (context, state, __) { return switch (state) { - AsyncError(:final error) => + AsyncError(:final error) => ErrorIndicator(message: error.toString()), - AsyncLoaded(:final value) => + AsyncLoaded(:final value) => value.isComplete ? CompletedActivitySessionView(state.value, controller) - : _VocabActivityView(controller), + : _AnalyticsActivityView(controller), _ => loading, }; }, @@ -93,10 +93,10 @@ class VocabPracticeView extends StatelessWidget { } } -class _VocabActivityView extends StatelessWidget { - final VocabPracticeState controller; +class _AnalyticsActivityView extends StatelessWidget { + final AnalyticsPracticeState controller; - const _VocabActivityView( + const _AnalyticsActivityView( this.controller, ); @@ -119,10 +119,10 @@ class _VocabActivityView extends StatelessWidget { Expanded( flex: 1, child: ValueListenableBuilder( - valueListenable: controller.activityConstructId, - builder: (context, constructId, __) => constructId != null + valueListenable: controller.activityText, + builder: (context, text, __) => text != null ? Text( - constructId.lemma, + text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( @@ -132,19 +132,19 @@ class _VocabActivityView extends StatelessWidget { : const SizedBox(), ), ), - Expanded( - flex: 2, - child: Center( - child: ValueListenableBuilder( - valueListenable: controller.activityConstructId, - builder: (context, constructId, __) => constructId != null - ? _ExampleMessageWidget( - controller.getExampleMessage(constructId), - ) - : const SizedBox(), - ), - ), - ), + // Expanded( + // flex: 2, + // child: Center( + // child: ValueListenableBuilder( + // valueListenable: controller.activityConstructId, + // builder: (context, constructId, __) => constructId != null + // ? _ExampleMessageWidget( + // controller.getExampleMessage(constructId), + // ) + // : const SizedBox(), + // ), + // ), + // ), Expanded( flex: 6, child: _ActivityChoicesWidget(controller), @@ -199,7 +199,7 @@ class _ExampleMessageWidget extends StatelessWidget { } class _ActivityChoicesWidget extends StatelessWidget { - final VocabPracticeState controller; + final AnalyticsPracticeState controller; const _ActivityChoicesWidget( this.controller, diff --git a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart similarity index 93% rename from lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart index dce47c6ee..a7a26864e 100644 --- a/lib/pangea/vocab_practice/choice_cards/audio_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/audio_choice_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Displays an audio button with a select label in a row layout diff --git a/lib/pangea/vocab_practice/choice_cards/game_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/game_choice_card.dart similarity index 100% rename from lib/pangea/vocab_practice/choice_cards/game_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/game_choice_card.dart diff --git a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart similarity index 96% rename from lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart rename to lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart index d4d004074..f51394054 100644 --- a/lib/pangea/vocab_practice/choice_cards/meaning_choice_card.dart +++ b/lib/pangea/analytics_practice/choice_cards/meaning_choice_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart'; /// Choice card for meaning activity with emoji, and alt text on flip class MeaningChoiceCard extends StatelessWidget { diff --git a/lib/pangea/vocab_practice/completed_activity_session_view.dart b/lib/pangea/analytics_practice/completed_activity_session_view.dart similarity index 93% rename from lib/pangea/vocab_practice/completed_activity_session_view.dart rename to lib/pangea/analytics_practice/completed_activity_session_view.dart index 5b9761f1f..28c0cebec 100644 --- a/lib/pangea/vocab_practice/completed_activity_session_view.dart +++ b/lib/pangea/analytics_practice/completed_activity_session_view.dart @@ -3,18 +3,18 @@ import 'package:flutter/material.dart'; 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'; import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart'; -import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart'; -import 'package:fluffychat/pangea/vocab_practice/stat_card.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; class CompletedActivitySessionView extends StatelessWidget { - final VocabPracticeSessionModel session; - final VocabPracticeState controller; + final AnalyticsPracticeSessionModel session; + final AnalyticsPracticeState controller; const CompletedActivitySessionView( this.session, this.controller, { @@ -198,7 +198,7 @@ class TimeStarsWidget extends StatelessWidget { }); int get starCount { - const timeForBonus = VocabPracticeConstants.timeForBonus; + const timeForBonus = AnalyticsPracticeConstants.timeForBonus; if (elapsedSeconds <= timeForBonus) return 5; if (elapsedSeconds <= timeForBonus * 1.5) return 4; if (elapsedSeconds <= timeForBonus * 2) return 3; diff --git a/lib/pangea/analytics_practice/morph_category_activity_generator.dart b/lib/pangea/analytics_practice/morph_category_activity_generator.dart new file mode 100644 index 000000000..66db19137 --- /dev/null +++ b/lib/pangea/analytics_practice/morph_category_activity_generator.dart @@ -0,0 +1,65 @@ +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; +import 'package:fluffychat/pangea/morphs/morph_models.dart'; +import 'package:fluffychat/pangea/morphs/morph_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class MorphCategoryActivityGenerator { + static Future get( + MessageActivityRequest req, + ) async { + if (req.targetMorphFeature == null) { + throw ArgumentError( + "MorphCategoryActivityGenerator requires a targetMorphFeature", + ); + } + + final feature = req.targetMorphFeature!; + final morphTag = req.targetTokens.first.getMorphTag(feature); + if (morphTag == null) { + throw ArgumentError( + "Token does not have the specified morph feature", + ); + } + + MorphFeaturesAndTags morphs = defaultMorphMapping; + + try { + final resp = await MorphsRepo.get(); + morphs = resp; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: {"l2": MatrixState.pangeaController.userController.userL2}, + ); + } + + final List allTags = morphs.getDisplayTags(feature.name); + final List possibleDistractors = allTags + .where( + (tag) => tag.toLowerCase() != morphTag.toLowerCase() && tag != "X", + ) + .toList(); + + possibleDistractors.shuffle(); + final choices = possibleDistractors.take(3).toSet(); + choices.add(morphTag); + + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: req.targetType, + targetTokens: [req.targetTokens.first], + langCode: req.userL2, + morphFeature: feature, + multipleChoiceContent: MultipleChoiceActivity( + choices: choices, + answers: {morphTag}, + ), + ), + ); + } +} diff --git a/lib/pangea/vocab_practice/percent_marker_bar.dart b/lib/pangea/analytics_practice/percent_marker_bar.dart similarity index 100% rename from lib/pangea/vocab_practice/percent_marker_bar.dart rename to lib/pangea/analytics_practice/percent_marker_bar.dart diff --git a/lib/pangea/vocab_practice/vocab_timer_widget.dart b/lib/pangea/analytics_practice/practice_timer_widget.dart similarity index 87% rename from lib/pangea/vocab_practice/vocab_timer_widget.dart rename to lib/pangea/analytics_practice/practice_timer_widget.dart index 2ef4d06af..005efe0cc 100644 --- a/lib/pangea/vocab_practice/vocab_timer_widget.dart +++ b/lib/pangea/analytics_practice/practice_timer_widget.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -class VocabTimerWidget extends StatefulWidget { +class PracticeTimerWidget extends StatefulWidget { final int initialSeconds; final ValueChanged onTimeUpdate; final bool isRunning; - const VocabTimerWidget({ + const PracticeTimerWidget({ required this.initialSeconds, required this.onTimeUpdate, this.isRunning = true, @@ -15,10 +15,10 @@ class VocabTimerWidget extends StatefulWidget { }); @override - VocabTimerWidgetState createState() => VocabTimerWidgetState(); + PracticeTimerWidgetState createState() => PracticeTimerWidgetState(); } -class VocabTimerWidgetState extends State { +class PracticeTimerWidgetState extends State { final Stopwatch _stopwatch = Stopwatch(); late int _initialSeconds; Timer? _timer; @@ -33,7 +33,7 @@ class VocabTimerWidgetState extends State { } @override - void didUpdateWidget(VocabTimerWidget oldWidget) { + void didUpdateWidget(PracticeTimerWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.isRunning && !widget.isRunning) { _stopTimer(); diff --git a/lib/pangea/vocab_practice/stat_card.dart b/lib/pangea/analytics_practice/stat_card.dart similarity index 100% rename from lib/pangea/vocab_practice/stat_card.dart rename to lib/pangea/analytics_practice/stat_card.dart diff --git a/lib/pangea/vocab_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart similarity index 100% rename from lib/pangea/vocab_practice/vocab_audio_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_audio_activity_generator.dart diff --git a/lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart similarity index 100% rename from lib/pangea/vocab_practice/vocab_meaning_activity_generator.dart rename to lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 6a3134f5b..42696c226 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; enum ActivityTypeEnum { @@ -13,7 +14,8 @@ enum ActivityTypeEnum { morphId, messageMeaning, lemmaMeaning, - lemmaAudio; + lemmaAudio, + grammarCategory; bool get includeTTSOnClick { switch (this) { @@ -27,6 +29,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.lemmaAudio: case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.grammarCategory: return true; } } @@ -62,6 +65,9 @@ enum ActivityTypeEnum { case 'lemma_audio': case 'lemmaAudio': return ActivityTypeEnum.lemmaAudio; + case 'grammar_category': + case 'grammarCategory': + return ActivityTypeEnum.grammarCategory; default: throw Exception('Unknown activity type: $split'); } @@ -117,6 +123,11 @@ enum ActivityTypeEnum { ConstructUseTypeEnum.corLM, ConstructUseTypeEnum.incLM, ]; + case ActivityTypeEnum.grammarCategory: + return [ + ConstructUseTypeEnum.corGC, + ConstructUseTypeEnum.incGC, + ]; } } @@ -140,6 +151,8 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.corLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.corLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.corGC; } } @@ -163,6 +176,8 @@ enum ActivityTypeEnum { return ConstructUseTypeEnum.incLA; case ActivityTypeEnum.lemmaMeaning: return ConstructUseTypeEnum.incLM; + case ActivityTypeEnum.grammarCategory: + return ConstructUseTypeEnum.incGC; } } @@ -182,6 +197,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.morphId: return Icons.format_shapes; case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.grammarCategory: return Icons.star; // TODO: Add to L10n } } @@ -200,6 +216,7 @@ enum ActivityTypeEnum { case ActivityTypeEnum.messageMeaning: case ActivityTypeEnum.lemmaMeaning: case ActivityTypeEnum.lemmaAudio: + case ActivityTypeEnum.grammarCategory: return 1; } } @@ -210,4 +227,41 @@ enum ActivityTypeEnum { ActivityTypeEnum.wordFocusListening, ActivityTypeEnum.morphId, ]; + + static List get vocabPracticeTypes => [ + ActivityTypeEnum.lemmaMeaning, + // ActivityTypeEnum.lemmaAudio, + ]; + + static List get grammarPracticeTypes => [ + ActivityTypeEnum.grammarCategory, + ]; + + static List analyticsPracticeTypes( + ConstructTypeEnum constructType, + ) { + switch (constructType) { + case ConstructTypeEnum.vocab: + return vocabPracticeTypes; + case ConstructTypeEnum.morph: + return grammarPracticeTypes; + } + } + + ConstructTypeEnum get constructType { + switch (this) { + case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.messageMeaning: + case ActivityTypeEnum.lemmaMeaning: + case ActivityTypeEnum.lemmaAudio: + return ConstructTypeEnum.vocab; + case ActivityTypeEnum.morphId: + case ActivityTypeEnum.grammarCategory: + return ConstructTypeEnum.morph; + } + } } diff --git a/lib/pangea/practice_activities/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart index 1200440b1..50542f32f 100644 --- a/lib/pangea/practice_activities/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -70,6 +70,15 @@ class MessageActivityRequest { } } + String get activityText { + switch (targetType) { + case ActivityTypeEnum.grammarCategory: + return "${targetTokens.first.vocabConstructID.lemma}: ${targetMorphFeature!.name}"; + default: + return targetTokens.first.vocabConstructID.lemma; + } + } + Map toJson() { return { 'user_l1': userL1, diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index d884e1315..b97955a40 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -47,6 +48,45 @@ class PracticeActivityModel { } } + String get useCategory { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + assert( + morphFeature != null, + "morphFeature is null in PracticeActivityModel.useCategory", + ); + return morphFeature!.name; + case ConstructTypeEnum.vocab: + return targetTokens.first.pos; + } + } + + String get useLemma { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + assert( + morphFeature != null, + "morphFeature is null in PracticeActivityModel.useCategory", + ); + final tag = targetTokens.first.getMorphTag(morphFeature!); + if (tag == null) { + throw ("tag is null in PracticeActivityModel.useLemma"); + } + return tag; + case ConstructTypeEnum.vocab: + return targetTokens.first.lemma.text; + } + } + + String get useForm { + switch (activityType.constructType) { + case ConstructTypeEnum.morph: + return targetTokens.first.lemma.form; + case ConstructTypeEnum.vocab: + return targetTokens.first.lemma.text; + } + } + PracticeTarget get practiceTarget => PracticeTarget( tokens: targetTokens, activityType: activityType, diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index 118f01a9e..84883cd50 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -9,6 +9,9 @@ import 'package:async/async.dart'; import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; +import 'package:fluffychat/pangea/analytics_practice/morph_category_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_audio_activity_generator.dart'; +import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; @@ -21,8 +24,6 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Represents an item in the completion cache. @@ -125,6 +126,8 @@ class PracticeRepo { return VocabMeaningActivityGenerator.get(req); case ActivityTypeEnum.lemmaAudio: return VocabAudioActivityGenerator.get(req); + case ActivityTypeEnum.grammarCategory: + return MorphCategoryActivityGenerator.get(req); case ActivityTypeEnum.morphId: return MorphActivityGenerator.get(req); case ActivityTypeEnum.wordMeaning: diff --git a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart b/lib/pangea/vocab_practice/vocab_practice_session_repo.dart deleted file mode 100644 index 2ce376e37..000000000 --- a/lib/pangea/vocab_practice/vocab_practice_session_repo.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:math'; - -import 'package:get_storage/get_storage.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart'; -import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class VocabPracticeSessionRepo { - static final GetStorage _storage = GetStorage('vocab_practice_session'); - - static Future get() async { - final cached = _getCached(); - if (cached != null) { - return cached; - } - - final r = Random(); - final activityTypes = [ - ActivityTypeEnum.lemmaMeaning, - //ActivityTypeEnum.lemmaAudio, - ]; - - final types = List.generate( - VocabPracticeConstants.practiceGroupSize, - (_) => activityTypes[r.nextInt(activityTypes.length)], - ); - - final constructs = await _fetch(); - final targetCount = min(constructs.length, types.length); - final targets = [ - for (var i = 0; i < targetCount; i++) - PracticeTarget( - tokens: [constructs[i].asToken], - activityType: types[i], - ), - ]; - - final session = VocabPracticeSessionModel( - userL1: MatrixState.pangeaController.userController.userL1!.langCode, - userL2: MatrixState.pangeaController.userController.userL2!.langCode, - startedAt: DateTime.now(), - practiceTargets: targets, - ); - await _setCached(session); - return session; - } - - static Future update( - VocabPracticeSessionModel session, - ) => - _setCached(session); - - static Future clear() => _storage.erase(); - - static Future> _fetch() async { - final constructs = await MatrixState - .pangeaController.matrixState.analyticsDataService - .getAggregatedConstructs(ConstructTypeEnum.vocab) - .then((map) => map.values.toList()); - - // sort by last used descending, nulls first - constructs.sort((a, b) { - final dateA = a.lastUsed; - final dateB = b.lastUsed; - if (dateA == null && dateB == null) return 0; - if (dateA == null) return -1; - if (dateB == null) return 1; - return dateA.compareTo(dateB); - }); - - return constructs - .where((construct) => construct.lemma.isNotEmpty) - .take(VocabPracticeConstants.practiceGroupSize) - .map((construct) => construct.id) - .toList(); - } - - static VocabPracticeSessionModel? _getCached() { - final keys = List.from(_storage.getKeys()); - if (keys.isEmpty) return null; - try { - final json = _storage.read(keys.first) as Map; - return VocabPracticeSessionModel.fromJson(json); - } catch (e) { - _storage.remove(keys.first); - return null; - } - } - - static Future _setCached(VocabPracticeSessionModel session) async { - await _storage.erase(); - await _storage.write( - session.startedAt.toIso8601String(), - session.toJson(), - ); - } -}