From 660b92fdf102f35150b446c3f1ec4524afe6a6c0 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:33:51 -0500 Subject: [PATCH] refactor: reorganize / simplify practice mode (#4755) * refactor: reorganize / simplify practice mode * cleanup * remove unreferenced code * only use content words in emoji activities --- lib/pages/chat/events/audio_player.dart | 3 - lib/pages/chat/events/html_message.dart | 7 +- lib/pages/chat/events/message_content.dart | 1 - lib/pangea/choreographer/choreographer.dart | 21 +- .../common/controllers/pangea_controller.dart | 4 - lib/pangea/common/utils/any_state_holder.dart | 7 +- .../constructs/construct_identifier.dart | 119 ---- .../controllers/message_data_controller.dart | 28 +- .../event_wrappers/pangea_message_event.dart | 76 +-- .../pangea_representation_event.dart | 7 +- .../extensions/pangea_event_extension.dart | 3 - .../events/models/pangea_token_model.dart | 302 +--------- .../token_practice_button.dart | 565 +++++++----------- .../message_token_text/tokens_util.dart | 4 +- .../activity_display_instructions_enum.dart | 6 - .../activity_type_enum.dart | 44 +- .../emoji_activity_generator.dart | 4 +- .../lemma_activity_generator.dart | 28 +- .../lemma_meaning_activity_generator.dart | 106 +--- .../morph_activity_generator.dart | 24 +- .../multiple_choice_activity_model.dart | 58 +- .../practice_activity_model.dart | 192 +----- .../practice_generation_repo.dart | 216 +++---- .../practice_activities/practice_match.dart | 4 - .../practice_activities/practice_record.dart | 190 +----- .../practice_record_repo.dart | 107 ++-- .../practice_selection.dart | 338 +---------- .../practice_selection_repo.dart | 271 ++++++--- .../practice_activities/practice_target.dart | 13 +- .../relevant_span_display_details.dart | 60 -- .../word_focus_listening_generator.dart | 87 +-- .../toolbar/enums/message_mode_enum.dart | 316 +--------- .../practice_activity_event.dart | 79 --- .../practice_activity_record_event.dart | 24 - .../message_morph_choice.dart | 36 +- .../practice_match_card.dart | 22 +- .../practice_match_item.dart | 26 +- .../reading_assistance_input_bar.dart | 274 +++++---- .../toolbar/widgets/message_audio_card.dart | 26 +- .../toolbar/widgets/message_meaning_card.dart | 45 -- .../widgets/message_selection_overlay.dart | 257 +------- .../widgets/message_selection_positioner.dart | 53 +- .../toolbar/widgets/over_message_overlay.dart | 7 +- .../widgets/overlay_center_content.dart | 6 +- .../toolbar/widgets/overlay_message.dart | 3 + .../multiple_choice_activity.dart | 260 -------- .../practice_activity_card.dart | 303 ++-------- .../toolbar/widgets/practice_controller.dart | 171 ++++++ .../widgets/practice_mode_buttons.dart | 75 --- .../practice_mode_transition_animation.dart | 139 +++-- .../toolbar/widgets/toolbar_button.dart | 65 +- 51 files changed, 1303 insertions(+), 3779 deletions(-) delete mode 100644 lib/pangea/practice_activities/activity_display_instructions_enum.dart delete mode 100644 lib/pangea/practice_activities/relevant_span_display_details.dart delete mode 100644 lib/pangea/toolbar/event_wrappers/practice_activity_event.dart delete mode 100644 lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart delete mode 100644 lib/pangea/toolbar/widgets/message_meaning_card.dart delete mode 100644 lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart create mode 100644 lib/pangea/toolbar/widgets/practice_controller.dart delete mode 100644 lib/pangea/toolbar/widgets/practice_mode_buttons.dart diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index edfa12263..2a445d9d5 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -14,7 +14,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/utils/error_reporter.dart'; @@ -35,7 +34,6 @@ class AudioPlayerWidget extends StatefulWidget { final String roomId; final String senderId; final PangeaAudioFile? matrixFile; - final ChatController chatController; final MessageOverlayController? overlayController; final bool autoplay; // Pangea# @@ -52,7 +50,6 @@ class AudioPlayerWidget extends StatefulWidget { required this.roomId, required this.senderId, this.matrixFile, - required this.chatController, this.overlayController, this.autoplay = false, // Pangea# diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 83b0f33ac..1103bfc0d 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -449,10 +449,12 @@ class HtmlMessage extends StatelessWidget { overlayController!.showTokenEmojiPopup(token), selectModeNotifier: overlayController!.selectedMode, ), - if (renderer.showCenterStyling && token != null) + if (renderer.showCenterStyling && + token != null && + overlayController != null) TokenPracticeButton( token: token, - overlayController: overlayController, + controller: overlayController!.practiceController, textStyle: renderer.style( context, color: renderer.backgroundColor( @@ -465,7 +467,6 @@ class HtmlMessage extends StatelessWidget { ), ), width: tokenWidth, - animateIn: isTransitionAnimation, textColor: textColor, ), CompositedTransformTarget( diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 213ff19f8..9f31ec73d 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -214,7 +214,6 @@ class MessageContent extends StatelessWidget { linkColor: linkColor, fontSize: fontSize, // #Pangea - chatController: controller, eventId: "${event.eventId}${overlayController != null ? '_overlay' : ''}", roomId: event.room.id, diff --git a/lib/pangea/choreographer/choreographer.dart b/lib/pangea/choreographer/choreographer.dart index 84ea96247..210a8ed16 100644 --- a/lib/pangea/choreographer/choreographer.dart +++ b/lib/pangea/choreographer/choreographer.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/choreographer/it/completed_it_step_model.dart' import 'package:fluffychat/pangea/choreographer/pangea_message_content_model.dart'; import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart'; import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart'; +import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; @@ -264,17 +265,15 @@ class Choreographer extends ChangeNotifier { final l1LangCode = MatrixState.pangeaController.languageController.userL1?.langCode; if (l1LangCode != null && l2LangCode != null) { - final res = await MatrixState.pangeaController.messageData - .getTokens( - repEventId: null, - room: null, - req: TokensRequestModel( - fullText: message, - senderL1: l1LangCode, - senderL2: l2LangCode, - ), - ) - .timeout(const Duration(seconds: 10)); + final res = await MessageDataController.getTokens( + repEventId: null, + room: null, + req: TokensRequestModel( + fullText: message, + senderL1: l1LangCode, + senderL2: l2LangCode, + ), + ).timeout(const Duration(seconds: 10)); tokensResp = res.isValue ? res.result : null; } diff --git a/lib/pangea/common/controllers/pangea_controller.dart b/lib/pangea/common/controllers/pangea_controller.dart index 5cd4e6dc0..7d8d7ef33 100644 --- a/lib/pangea/common/controllers/pangea_controller.dart +++ b/lib/pangea/common/controllers/pangea_controller.dart @@ -15,7 +15,6 @@ import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart'; import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; import 'package:fluffychat/pangea/learning_settings/controllers/language_controller.dart'; @@ -39,8 +38,6 @@ class PangeaController { late PermissionsController permissionsController; late GetAnalyticsController getAnalytics; late PutAnalyticsController putAnalytics; - late MessageDataController messageData; - late SubscriptionController subscriptionController; late TextToSpeechController textToSpeech; late SpeechToTextController speechToText; @@ -86,7 +83,6 @@ class PangeaController { permissionsController = PermissionsController(this); getAnalytics = GetAnalyticsController(this); putAnalytics = PutAnalyticsController(this); - messageData = MessageDataController(this); subscriptionController = SubscriptionController(this); textToSpeech = TextToSpeechController(this); speechToText = SpeechToTextController(this); diff --git a/lib/pangea/common/utils/any_state_holder.dart b/lib/pangea/common/utils/any_state_holder.dart index 64ea7936e..30e10d502 100644 --- a/lib/pangea/common/utils/any_state_holder.dart +++ b/lib/pangea/common/utils/any_state_holder.dart @@ -131,8 +131,11 @@ class PangeaAnyState { } } - RenderBox? getRenderBox(String key) => - layerLinkAndKey(key).key.currentContext?.findRenderObject() as RenderBox?; + RenderBox? getRenderBox(String key) { + final box = layerLinkAndKey(key).key.currentContext?.findRenderObject() + as RenderBox?; + return box?.hasSize == true ? box : null; + } bool isOverlayOpen(RegExp regex) { return entries.any( diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index d6f108e27..5d7174aef 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -1,7 +1,6 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -13,18 +12,14 @@ 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/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/emojis/emoji_stack.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; -import 'package:fluffychat/pangea/message_token_text/token_practice_button.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ConstructIdentifier { @@ -143,8 +138,6 @@ class ConstructIdentifier { ); } - String get partialKey => "$lemma-${type.string}"; - ConstructUses get constructUses => MatrixState.pangeaController.getAnalytics.constructListModel .getConstructUses( @@ -159,8 +152,6 @@ class ConstructIdentifier { List get userSetEmoji => userLemmaInfo?.emojis ?? []; - String? get userSetMeaning => userLemmaInfo?.meaning; - UserSetLemmaInfo? get userLemmaInfo { switch (type) { case ConstructTypeEnum.vocab: @@ -265,116 +256,6 @@ class ConstructIdentifier { _lemmaInfoRequest, ); - LemmaInfoResponse? getLemmaInfoCached([ - String? lemmaLang, - String? userl1, - ]) => - LemmaInfoRepo.getCached( - _lemmaInfoRequest, - ); - bool get isContentWord => PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false; - - /// [form] should be passed if available and is required for morphId - bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a, String? form) { - switch (a) { - case ActivityTypeEnum.wordMeaning: - final double contentModifier = isContentWord ? 0.5 : 1; - if (daysSinceLastEligibleUseForMeaning < - 3 * constructUses.points * contentModifier) { - return false; - } - - return true; - case ActivityTypeEnum.emoji: - return userSetEmoji.length < maxEmojisPerLemma; - case ActivityTypeEnum.morphId: - if (form == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: Exception( - "form is null in isActivityProbablyLevelAppropriate for morphId", - ), - data: { - "activity": a, - "construct": toJson(), - }, - ); - return false; - } - final uses = constructUses.uses - .where((u) => u.form == form) - .map((u) => u.timeStamp) - .toList(); - - if (uses.isEmpty) return true; - - final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b); - - return DateTime.now().difference(lastUsed).inDays > - 1 * constructUses.points; - case ActivityTypeEnum.wordFocusListening: - final pos = PartOfSpeechEnumExtensions.fromString(lemma) ?? - PartOfSpeechEnumExtensions.fromString(category); - - if (pos == null) { - debugger(when: kDebugMode); - return false; - } - - return pos.canBeHeard; - default: - debugger(when: kDebugMode); - ErrorHandler.logError( - e: Exception( - "Activity type $a not handled in ConstructIdentifier.isActivityProbablyLevelAppropriate", - ), - data: { - "activity": a, - "construct": toJson(), - }, - ); - return false; - } - } - - /// days since last eligible use for meaning - /// this is the number of days since the last time the user used this word - /// in a way that would engage with the meaning of the word - /// importantly, this excludes emoji activities - /// we want users to be able to do an emoji activity as a ramp up to - /// a word meaning activity - int get daysSinceLastEligibleUseForMeaning { - final times = constructUses.uses - .where( - (u) => - u.useType.sentByUser || - ActivityTypeEnum.wordMeaning.associatedUseTypes - .contains(u.useType) || - ActivityTypeEnum.messageMeaning.associatedUseTypes - .contains(u.useType), - ) - .map((u) => u.timeStamp) - .toList(); - - if (times.isEmpty) return 1000; - - // return the most recent timestamp - final last = times.reduce((a, b) => a.isAfter(b) ? a : b); - - return DateTime.now().difference(last).inDays; - } - - Widget get visual { - switch (type) { - case ConstructTypeEnum.vocab: - return EmojiStack(emoji: userSetEmoji); - case ConstructTypeEnum.morph: - return MorphIcon( - morphFeature: MorphFeaturesEnumExtension.fromString(category), - morphTag: lemma, - ); - } - } } diff --git a/lib/pangea/events/controllers/message_data_controller.dart b/lib/pangea/events/controllers/message_data_controller.dart index 01a024a92..30319ce7c 100644 --- a/lib/pangea/events/controllers/message_data_controller.dart +++ b/lib/pangea/events/controllers/message_data_controller.dart @@ -3,8 +3,6 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:matrix/matrix.dart' hide Result; -import 'package:fluffychat/pangea/common/controllers/base_controller.dart'; -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; @@ -21,16 +19,10 @@ import 'package:fluffychat/widgets/matrix.dart'; // TODO - make this static and take it out of the _pangeaController // will need to pass accessToken to the requests -class MessageDataController extends BaseController { - late PangeaController _pangeaController; - - MessageDataController(PangeaController pangeaController) { - _pangeaController = pangeaController; - } - +class MessageDataController { /// get tokens from the server /// if repEventId is not null, send the tokens to the room - Future> getTokens({ + static Future> getTokens({ required String? repEventId, required TokensRequestModel req, required Room? room, @@ -67,18 +59,18 @@ class MessageDataController extends BaseController { /// if in cache, return from cache /// if not in cache, get from server /// send the translation to the room as a representation event - Future getPangeaRepresentation({ + static Future getPangeaRepresentation({ required FullTextTranslationRequestModel req, required Event messageEvent, }) => _getPangeaRepresentation(req: req, messageEvent: messageEvent); - Future _getPangeaRepresentation({ + static Future _getPangeaRepresentation({ required FullTextTranslationRequestModel req, required Event messageEvent, }) async { final res = await FullTextTranslationRepo.get( - _pangeaController.userController.accessToken, + MatrixState.pangeaController.userController.accessToken, req, ); @@ -111,13 +103,13 @@ class MessageDataController extends BaseController { return rep; } - Future getPangeaRepresentationEvent({ + static Future getPangeaRepresentationEvent({ required FullTextTranslationRequestModel req, required PangeaMessageEvent messageEvent, bool originalSent = false, }) async { final res = await FullTextTranslationRepo.get( - _pangeaController.userController.accessToken, + MatrixState.pangeaController.userController.accessToken, req, ); @@ -154,7 +146,7 @@ class MessageDataController extends BaseController { } } - Future getSttTranslation({ + static Future getSttTranslation({ required String? repEventId, required FullTextTranslationRequestModel req, required Room? room, @@ -165,13 +157,13 @@ class MessageDataController extends BaseController { room: room, ); - Future _getSttTranslation({ + static Future _getSttTranslation({ required String? repEventId, required FullTextTranslationRequestModel req, required Room? room, }) async { final res = await FullTextTranslationRepo.get( - _pangeaController.userController.accessToken, + MatrixState.pangeaController.userController.accessToken, req, ); diff --git a/lib/pangea/events/event_wrappers/pangea_message_event.dart b/lib/pangea/events/event_wrappers/pangea_message_event.dart index 5c7d3f244..d75e26855 100644 --- a/lib/pangea/events/event_wrappers/pangea_message_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_message_event.dart @@ -10,8 +10,8 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/stt_translation_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; @@ -19,12 +19,9 @@ import 'package:fluffychat/pangea/events/repo/language_detection_repo.dart'; import 'package:fluffychat/pangea/events/repo/language_detection_request.dart'; import 'package:fluffychat/pangea/events/repo/language_detection_response.dart'; import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; -import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; -import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/spaces/models/space_model.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/audio_encoding_enum.dart'; -import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/translation/full_text_translation_request_model.dart'; @@ -562,8 +559,7 @@ class PangeaMessageEvent { // clear representations cache so the new representation event can be added when next requested _representations = null; - return MatrixState.pangeaController.messageData - .getPangeaRepresentationEvent( + return MessageDataController.getPangeaRepresentationEvent( req: FullTextTranslationRequestModel( text: originalSent?.content.text ?? _latestEdit.body, srcLang: originalSent?.langCode, @@ -607,7 +603,7 @@ class PangeaMessageEvent { // clear representations cache so the new representation event can be added when next requested _representations = null; - return MatrixState.pangeaController.messageData.getPangeaRepresentation( + return MessageDataController.getPangeaRepresentation( req: FullTextTranslationRequestModel( text: includedIT ? originalWrittenContent : messageDisplayText, srcLang: srcLang, @@ -642,11 +638,6 @@ class PangeaMessageEvent { String? get l1Code => MatrixState.pangeaController.languageController.userL1?.langCode; - /// Should almost always be true. Useful in the case that the message - /// display rep has the langCode "unk" - bool get messageDisplayLangIsL2 => - messageDisplayLangCode.split("-")[0] == l2Code?.split("-")[0]; - String get messageDisplayLangCode { if (isAudioMessage) { final stt = getSpeechToTextLocal(); @@ -672,67 +663,6 @@ class PangeaMessageEvent { /// it returns the message body. String get messageDisplayText => messageDisplayRepresentation?.text ?? body; - /// Returns a list of all [PracticeActivityEvent] objects - /// associated with this message event. - List get _practiceActivityEvents { - final List events = _latestEdit - .aggregatedEvents( - timeline, - PangeaEventTypes.pangeaActivity, - ) - .where((event) => !event.redacted) - .toList(); - - final List practiceEvents = []; - for (final event in events) { - try { - practiceEvents.add( - PracticeActivityEvent( - timeline: timeline, - event: event, - ), - ); - } catch (e, s) { - ErrorHandler.logError(e: e, s: s, data: event.toJson()); - } - } - return practiceEvents; - } - - /// Returns a list of [PracticeActivityEvent] objects for the given [langCode]. - List practiceActivitiesByLangCode( - String langCode, { - bool debug = false, - }) => - _practiceActivityEvents - .where( - (event) => - event.practiceActivity.langCode.split("-")[0] == - langCode.split("")[0], - ) - .toList(); - - /// Returns a list of [PracticeActivityEvent] for the user's active l2. - List get practiceActivities => - l2Code == null ? [] : practiceActivitiesByLangCode(l2Code!); - - bool shouldDoActivity({ - required PangeaToken? token, - required ActivityTypeEnum a, - required MorphFeaturesEnum? feature, - required String? tag, - }) { - if (!messageDisplayLangIsL2 || token == null) { - return false; - } - - return token.shouldDoActivity( - a: a, - feature: feature, - tag: tag, - ); - } - TextDirection get textDirection => PLanguageStore.rtlLanguageCodes.contains(messageDisplayLangCode) ? TextDirection.rtl diff --git a/lib/pangea/events/event_wrappers/pangea_representation_event.dart b/lib/pangea/events/event_wrappers/pangea_representation_event.dart index ad040438a..6b5f81a75 100644 --- a/lib/pangea/events/event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_representation_event.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_choreo_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/language_detection_model.dart'; @@ -112,7 +113,7 @@ class RepresentationEvent { ), ); } - final res = await MatrixState.pangeaController.messageData.getTokens( + final res = await MessageDataController.getTokens( repEventId: _event?.eventId, room: _event?.room ?? parentMessageEvent.room, req: TokensRequestModel( @@ -151,7 +152,7 @@ class RepresentationEvent { return; } - await MatrixState.pangeaController.messageData.getTokens( + await MessageDataController.getTokens( repEventId: repEventID, room: room, req: TokensRequestModel( @@ -216,7 +217,7 @@ class RepresentationEvent { final local = sttTranslations.firstWhereOrNull((t) => t.langCode == userL1); if (local != null) return local; - return MatrixState.pangeaController.messageData.getSttTranslation( + return MessageDataController.getSttTranslation( repEventId: _event?.eventId, room: _event?.room, req: FullTextTranslationRequestModel( diff --git a/lib/pangea/events/extensions/pangea_event_extension.dart b/lib/pangea/events/extensions/pangea_event_extension.dart index aa99b690f..77014bfcf 100644 --- a/lib/pangea/events/extensions/pangea_event_extension.dart +++ b/lib/pangea/events/extensions/pangea_event_extension.dart @@ -11,7 +11,6 @@ import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; @@ -36,8 +35,6 @@ extension PangeaEvent on Event { return ChoreoRecordModel.fromJson(json) as V; case PangeaEventTypes.pangeaActivity: return PracticeActivityModel.fromJson(json) as V; - case PangeaEventTypes.activityRecord: - return PracticeRecord.fromJson(json) as V; default: debugger(when: kDebugMode); throw Exception("$type events do not have pangea content"); diff --git a/lib/pangea/events/models/pangea_token_model.dart b/lib/pangea/events/models/pangea_token_model.dart index 9a75ab117..6081fa585 100644 --- a/lib/pangea/events/models/pangea_token_model.dart +++ b/lib/pangea/events/models/pangea_token_model.dart @@ -4,22 +4,17 @@ import 'package:flutter/foundation.dart'; import 'package:collection/collection.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.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/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_repo.dart'; -import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../common/constants/model_keys.dart'; @@ -130,19 +125,8 @@ class PangeaToken { /// alias for the end of the token ie offset + length int get end => text.offset + text.length; - bool get isContentWord => vocabConstructID.isContentWord; - - String get analyticsDebugPrint => - "content: ${text.content} isContentWord: $isContentWord vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning, null)}"; - - bool get canBeDefined => - PartOfSpeechEnumExtensions.fromString(pos)?.canBeDefined ?? false; - - bool get canBeHeard => - PartOfSpeechEnumExtensions.fromString(pos)?.canBeHeard ?? false; - /// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma - OneConstructUse toVocabUse( + OneConstructUse _toVocabUse( ConstructUseTypeEnum type, ConstructUseMetaData metadata, int xp, @@ -166,7 +150,7 @@ class PangeaToken { final List uses = []; if (!lemma.saveVocab) return uses; - uses.add(toVocabUse(type, metadata, xp)); + uses.add(_toVocabUse(type, metadata, xp)); for (final morphFeature in morph.keys) { uses.add( OneConstructUse( @@ -184,150 +168,6 @@ class PangeaToken { return uses; } - bool isActivityBasicallyEligible( - ActivityTypeEnum a, [ - MorphFeaturesEnum? morphFeature, - String? morphTag, - ]) { - if (!lemma.saveVocab) { - return false; - } - - switch (a) { - case ActivityTypeEnum.wordMeaning: - return canBeDefined; - case ActivityTypeEnum.lemmaId: - return lemma.saveVocab && - text.content.toLowerCase() != lemma.text.toLowerCase(); - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.messageMeaning: - return true; - case ActivityTypeEnum.morphId: - return morph.isNotEmpty; - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.hiddenWordListening: - return canBeHeard; - } - } - - // bool _didActivity( - // ActivityTypeEnum a, [ - // String? morphFeature, - // String? morphTag, - // ]) { - // if ((morphFeature == null || morphTag == null) && - // a == ActivityTypeEnum.morphId) { - // debugger(when: kDebugMode); - // return true; - // } - - // switch (a) { - // case ActivityTypeEnum.wordMeaning: - // case ActivityTypeEnum.wordFocusListening: - // case ActivityTypeEnum.hiddenWordListening: - // case ActivityTypeEnum.lemmaId: - // case ActivityTypeEnum.emoji: - // case ActivityTypeEnum.messageMeaning: - // return vocabConstruct.uses - // .map((u) => u.useType) - // .any((u) => a.associatedUseTypes.contains(u)); - // case ActivityTypeEnum.morphId: - // return morph.entries - // .map((e) => morphConstruct(morphFeature!, morphTag!).uses) - // .expand((e) => e) - // .any( - // (u) => - // a.associatedUseTypes.contains(u.useType) && - // u.form == text.content, - // ); - // } - // } - - bool didActivitySuccessfully( - ActivityTypeEnum a, [ - MorphFeaturesEnum? morphFeature, - String? morphTag, - ]) { - if ((morphFeature == null || morphTag == null) && - a == ActivityTypeEnum.morphId) { - debugger(when: kDebugMode); - return true; - } - switch (a) { - case ActivityTypeEnum.wordMeaning: - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.hiddenWordListening: - case ActivityTypeEnum.lemmaId: - case ActivityTypeEnum.emoji: - return vocabConstruct.uses - .map((u) => u.useType) - .any((u) => u == a.correctUse); - // Note that it matters less if they did morphId in general, than if they did it with the particular feature - case ActivityTypeEnum.morphId: - // TODO: investigate if we take out condition "|| morphTag == null", will we get the expected number of morph activities? - if (morphFeature == null || morphTag == null) { - debugger(when: kDebugMode); - return false; - } - return morphConstruct(morphFeature)?.uses.any( - (u) => u.useType == a.correctUse && u.form == text.content, - ) ?? - false; - case ActivityTypeEnum.messageMeaning: - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not call didActivitySuccessfully for ActivityTypeEnum.messageMeaning", - data: toJson(), - ); - return true; - } - } - - bool _isActivityProbablyLevelAppropriate( - ActivityTypeEnum a, [ - MorphFeaturesEnum? morphFeature, - ]) { - switch (a) { - case ActivityTypeEnum.wordMeaning: - return vocabConstructID.isActivityProbablyLevelAppropriate( - a, - text.content, - ); - - case ActivityTypeEnum.wordFocusListening: - return !didActivitySuccessfully(a) || - daysSinceLastUseByType(a, null) > 30; - case ActivityTypeEnum.hiddenWordListening: - return daysSinceLastUseByType(a, null) > 7; - case ActivityTypeEnum.lemmaId: - return false; - // disabling lemma activities for now - // It has 2 purposes: - // • learning value - // • triangulating our determination of the lemma with AI plus user verification. - // However, displaying the lemma during the meaning activity helps - // disambiguate what the meaning activity is about. This is probably more valuable than the - // lemma activity itself. The piping for the lemma activity will stay there if we want to turn - // it back on, maybe in select instances. - // return _didActivitySuccessfully(ActivityTypeEnum.wordMeaning) && - // daysSinceLastUseByType(a) > 7; - case ActivityTypeEnum.emoji: - return vocabConstructID.isActivityProbablyLevelAppropriate( - a, - text.content, - ); - - case ActivityTypeEnum.messageMeaning: - return true; - case ActivityTypeEnum.morphId: - return morphFeature != null - ? morphIdByFeature(morphFeature) - ?.isActivityProbablyLevelAppropriate(a, text.content) ?? - false - : false; - } - } - /// Safely get morph tag for a given feature without regard for case String? getMorphTag(MorphFeaturesEnum feature) { // if the morph contains the feature, return it @@ -336,16 +176,6 @@ class PangeaToken { return null; } - // maybe for every 5 points of xp for a particular activity, increment the days between uses by 2 - bool shouldDoActivity({ - required ActivityTypeEnum a, - required MorphFeaturesEnum? feature, - required String? tag, - }) { - return isActivityBasicallyEligible(a, feature, tag) && - _isActivityProbablyLevelAppropriate(a, feature); - } - ConstructUses get vocabConstruct => MatrixState.pangeaController.getAnalytics.constructListModel .getConstructUses( @@ -358,9 +188,6 @@ class PangeaToken { uses: [], ); - ConstructUses? morphConstruct(MorphFeaturesEnum morphFeature) => - morphIdByFeature(morphFeature)?.constructUses; - ConstructIdentifier? morphIdByFeature(MorphFeaturesEnum feature) { final tag = getMorphTag(feature); if (tag == null) return null; @@ -405,36 +232,6 @@ class PangeaToken { return DateTime.now().difference(lastUsed).inDays; } - List get _constructIDs { - final List ids = []; - ids.add( - ConstructIdentifier( - lemma: lemma.text, - type: ConstructTypeEnum.vocab, - category: pos, - ), - ); - for (final morph in morph.entries) { - ids.add( - ConstructIdentifier( - lemma: morph.value, - type: ConstructTypeEnum.morph, - category: morph.key.name, - ), - ); - } - return ids; - } - - List get constructs => _constructIDs - .map( - (id) => MatrixState.pangeaController.getAnalytics.constructListModel - .getConstructUses(id), - ) - .where((construct) => construct != null) - .cast() - .toList(); - ConstructIdentifier get vocabConstructID => ConstructIdentifier( lemma: lemma.text, type: ConstructTypeEnum.vocab, @@ -447,23 +244,7 @@ class PangeaToken { Future setEmoji(List emojis) => vocabConstructID.setUserLemmaInfo(UserSetLemmaInfo(emojis: emojis)); - /// [getEmoji] gets the emoji for the lemma - /// NOTE: assumes that the language of the lemma is the same as the user's current l2 - List getEmoji() => vocabConstructID.userSetEmoji; - - String get xpEmoji => vocabConstruct.xpEmoji; - - ConstructLevelEnum get lemmaXPCategory { - if (vocabConstruct.points >= AnalyticsConstants.xpForFlower) { - return ConstructLevelEnum.flowers; - } else if (vocabConstruct.points >= AnalyticsConstants.xpForGreens) { - return ConstructLevelEnum.greens; - } else { - return ConstructLevelEnum.seeds; - } - } - - List morphActivityDistractors( + Set morphActivityDistractors( MorphFeaturesEnum morphFeature, String morphTag, ) { @@ -477,67 +258,9 @@ class PangeaToken { .toList(); possibleDistractors.shuffle(); - return possibleDistractors.take(numberOfMorphDistractors).toList(); + return possibleDistractors.take(numberOfMorphDistractors).toSet(); } - /// initial default input mode for a token - MessageMode get modeForToken { - // if (getEmoji() == null) { - // return MessageMode.wordEmoji; - // } - - if (shouldDoActivity( - a: ActivityTypeEnum.wordMeaning, - feature: null, - tag: null, - )) { - return MessageMode.wordMeaning; - } - - // final String? morph = nextMorphFeatureEligibleForActivity; - // if (morph != null) { - // debugPrint("should do morph activity for ${text.content}"); - // return MessageMode.wordMorph; - // } - - return MessageMode.wordZoom; - } - - List get allMorphFeatures => morph.keys.toList(); - - /// cycle through morphs to get the next one where should do morph activity is true - /// if none are found, return null - MorphFeaturesEnum? get nextMorphFeatureEligibleForActivity { - for (final m in morph.entries) { - if (shouldDoActivity( - a: ActivityTypeEnum.morphId, - feature: m.key, - tag: m.value, - )) { - return m.key; - } - } - - return null; - } - - bool get doesLemmaTextMatchTokenText { - return lemma.text.toLowerCase() == text.content.toLowerCase(); - } - - bool shouldDoActivityByMessageMode(MessageMode mode) { - // debugPrint("should do activity for ${text.content} in $mode"); - return mode.associatedActivityType != null - ? shouldDoActivity( - a: mode.associatedActivityType!, - feature: null, - tag: null, - ) - : false; - } - - List get allConstructIds => _constructIDs; - List get morphsBasicallyEligibleForPracticeByPriority => MorphFeaturesEnumExtension.eligibleForPractice.where((f) { return morph.containsKey(f); @@ -549,14 +272,6 @@ class PangeaToken { ); }).toList(); - bool hasMorph(ConstructIdentifier cId) { - return morph.entries.any( - (e) => - e.key.name == cId.lemma.toLowerCase() && - e.value.toString().toLowerCase() == cId.category.toLowerCase(), - ); - } - /// [0,infinity) - a higher number means higher priority int activityPriorityScore( ActivityTypeEnum a, @@ -566,5 +281,14 @@ class PangeaToken { (vocabConstructID.isContentWord ? 10 : 9); } + bool eligibleForPractice(ActivityTypeEnum activityType) { + switch (activityType) { + case ActivityTypeEnum.emoji: + return lemma.saveVocab && vocabConstructID.isContentWord; + default: + return lemma.saveVocab; + } + } + String get uniqueId => "${text.content}::${text.offset}"; } diff --git a/lib/pangea/message_token_text/token_practice_button.dart b/lib/pangea/message_token_text/token_practice_button.dart index 1e0717ecc..e0c7a7fb5 100644 --- a/lib/pangea/message_token_text/token_practice_button.dart +++ b/lib/pangea/message_token_text/token_practice_button.dart @@ -1,7 +1,5 @@ -import 'dart:developer'; import 'dart:math'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; @@ -9,7 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/message_token_text/dotted_border_painter.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; @@ -19,357 +17,130 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; const double tokenButtonHeight = 40.0; const double tokenButtonDefaultFontSize = 10; const int maxEmojisPerLemma = 1; -const double estimatedEmojiWidthRatio = 2; -class TokenPracticeButton extends StatefulWidget { - final MessageOverlayController? overlayController; +class TokenPracticeButton extends StatelessWidget { + final PracticeController controller; final PangeaToken token; final TextStyle textStyle; final double width; - final bool animateIn; final Color textColor; const TokenPracticeButton({ super.key, - required this.overlayController, + required this.controller, required this.token, required this.textStyle, required this.width, required this.textColor, - this.animateIn = false, - }); - - @override - TokenPracticeButtonState createState() => TokenPracticeButtonState(); -} - -class TokenPracticeButtonState extends State - with TickerProviderStateMixin { - AnimationController? _controller; - Animation? _heightAnimation; - - // New controller and animation for icon size - AnimationController? _iconSizeController; - Animation? _iconSizeAnimation; - - bool _isHovered = false; - bool _isSelected = false; - bool _finishedInitialAnimation = false; - bool _wasEmpty = false; - - @override - void initState() { - super.initState(); - - _controller = AnimationController( - vsync: this, - duration: const Duration( - milliseconds: AppConfig.overlayAnimationDuration, - ), - ); - - _heightAnimation = Tween( - begin: 0, - end: tokenButtonHeight, - ).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut)); - - // Initialize the new icon size controller and animation - _iconSizeController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - - _iconSizeAnimation = Tween( - begin: 24, // Default icon size - end: 30, // Enlarged icon size - ).animate( - CurvedAnimation(parent: _iconSizeController!, curve: Curves.easeInOut), - ); - - _setSelected(); // Call _setSelected after initializing _iconSizeController - - _wasEmpty = _isEmpty; - - if (!_isEmpty) { - _controller?.forward().then((_) { - if (mounted) setState(() => _finishedInitialAnimation = true); - }); - } else { - setState(() => _finishedInitialAnimation = true); - } - } - - @override - void didUpdateWidget(covariant TokenPracticeButton oldWidget) { - super.didUpdateWidget(oldWidget); - _setSelected(); - if (_isEmpty != _wasEmpty) { - if (_isEmpty && _animate) { - _controller?.reverse(); - } else if (!_isEmpty && _animate) { - _controller?.forward(); - } - setState(() => _wasEmpty = _isEmpty); - } - } - - @override - void dispose() { - _controller?.dispose(); - _iconSizeController?.dispose(); // Dispose the new controller - super.dispose(); - } - - PracticeTarget? get _activity => - widget.overlayController?.practiceTargetForToken(widget.token); - - bool get _animate => widget.animateIn || _finishedInitialAnimation; - - bool get _isActivityCompleteOrNullForToken => - _activity?.isCompleteByToken( - widget.token, - _activity!.morphFeature, - ) == - true; - - void _setSelected() { - final selected = - widget.overlayController?.selectedMorph?.token == widget.token && - widget.overlayController?.selectedMorph?.morph == - _activity?.morphFeature; - - if (selected != _isSelected) { - setState(() { - _isSelected = selected; - }); - - _isSelected - ? _iconSizeController?.forward() - : _iconSizeController?.reverse(); - } - } - - void _setHovered(bool isHovered) { - if (isHovered != _isHovered) { - setState(() { - _isHovered = isHovered; - }); - - if (!_isHovered && _isSelected) { - return; - } - - _isHovered - ? _iconSizeController?.forward() - : _iconSizeController?.reverse(); - } - } - - void _onMatch(PracticeChoice form) { - if (widget.overlayController?.activity == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not be in onAcceptWithDetails with null activity", - data: {"details": form}, - ); - return; - } - - widget.overlayController!.onChoiceSelect(null); - widget.overlayController!.onMatch(widget.token, form); - } - - bool get _isEmpty { - final mode = widget.overlayController?.toolbarMode; - if (MessageMode.wordEmoji == mode && - widget.token.vocabConstructID.userSetEmoji.firstOrNull != null) { - return false; - } - - return _activity == null || - (_isActivityCompleteOrNullForToken && - ![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) || - (MessageMode.wordMorph == mode && _activity?.morphFeature == null); - } - - @override - Widget build(BuildContext context) { - if (widget.overlayController == null) { - return const SizedBox.shrink(); - } - - if (!_animate && _iconSizeAnimation != null) { - return MessageTokenButtonContent( - activity: _activity, - messageMode: widget.overlayController!.toolbarMode, - token: widget.token, - selectedChoice: widget.overlayController?.selectedChoice, - isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken, - isSelected: _isSelected, - height: tokenButtonHeight, - width: widget.width, - textStyle: widget.textStyle, - textColor: widget.textColor, - sizeAnimation: _iconSizeAnimation!, - onHover: _setHovered, - onTap: () => widget.overlayController!.onMorphActivitySelect( - MorphSelection(widget.token, _activity!.morphFeature!), - ), - onMatch: _onMatch, - ); - } - - if (_heightAnimation != null && _iconSizeAnimation != null) { - return AnimatedBuilder( - animation: _heightAnimation!, - builder: (context, child) { - return MessageTokenButtonContent( - activity: _activity, - messageMode: widget.overlayController!.toolbarMode, - token: widget.token, - selectedChoice: widget.overlayController?.selectedChoice, - isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken, - isSelected: _isSelected, - height: _heightAnimation!.value, - width: widget.width, - textStyle: widget.textStyle, - textColor: widget.textColor, - sizeAnimation: _iconSizeAnimation!, - onHover: _setHovered, - onTap: () => widget.overlayController!.onMorphActivitySelect( - MorphSelection(widget.token, _activity!.morphFeature!), - ), - onMatch: _onMatch, - ); - }, - ); - } - - return const SizedBox.shrink(); - } -} - -class MessageTokenButtonContent extends StatelessWidget { - final PracticeTarget? activity; - final MessageMode messageMode; - final PangeaToken token; - final PracticeChoice? selectedChoice; - - final bool isActivityCompleteOrNullForToken; - final bool isSelected; - final double height; - final double width; - final TextStyle textStyle; - final Color textColor; - final Animation sizeAnimation; - - final Function(bool)? onHover; - final Function()? onTap; - final Function(PracticeChoice)? onMatch; - - const MessageTokenButtonContent({ - super.key, - required this.activity, - required this.messageMode, - required this.token, - required this.selectedChoice, - required this.isActivityCompleteOrNullForToken, - required this.isSelected, - required this.height, - required this.width, - required this.textStyle, - required this.textColor, - required this.sizeAnimation, - this.onHover, - this.onTap, - this.onMatch, }); TextStyle get _emojiStyle => TextStyle( fontSize: (textStyle.fontSize ?? tokenButtonDefaultFontSize) + 4, ); - static final _borderRadius = - BorderRadius.circular(AppConfig.borderRadius - 4); + PracticeTarget? get _activity => controller.practiceTargetForToken(token); + + bool get isActivityCompleteOrNullForToken { + return _activity?.isCompleteByToken( + token, + _activity!.morphFeature, + ) == + true; + } + + bool get _isEmpty { + final mode = controller.practiceMode; + if (MessageMode.wordEmoji == mode && + token.vocabConstructID.userSetEmoji.firstOrNull != null) { + return false; + } + + return _activity == null || + (isActivityCompleteOrNullForToken && + ![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) || + (MessageMode.wordMorph == mode && _activity?.morphFeature == null); + } + + bool get _isSelected => + controller.selectedMorph?.token == token && + controller.selectedMorph?.morph == _activity?.morphFeature; + + void _onMatch(PracticeChoice form) { + controller.onChoiceSelect(null); + controller.onMatch(token, form); + } @override Widget build(BuildContext context) { - if (isActivityCompleteOrNullForToken || activity == null) { - if (MessageMode.wordEmoji == messageMode) { - return SizedBox( - height: height, - child: Text( - activity?.record.responses - .firstWhereOrNull( - (res) => - res.cId == token.vocabConstructID && res.isCorrect, - ) - ?.text ?? - token.vocabConstructID.userSetEmoji.firstOrNull ?? - '', - style: _emojiStyle, - ), - ); - } - if (MessageMode.wordMorph == messageMode && activity != null) { - final morphFeature = activity!.morphFeature!; - final morphTag = token.morphIdByFeature(morphFeature); - if (morphTag != null) { - return Tooltip( - message: getGrammarCopy( - category: morphFeature.toShortString(), - lemma: morphTag.lemma, - context: context, - ), - child: SizedBox( - width: 24.0, - child: Center( - child: MorphIcon( - morphFeature: morphFeature, - morphTag: morphTag.lemma, - ), + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final practiceMode = controller.practiceMode; + + Widget child; + if (isActivityCompleteOrNullForToken || _activity == null) { + child = _NoActivityContentButton( + practiceMode: practiceMode, + token: token, + target: _activity, + emojiStyle: _emojiStyle, + ); + } else if (practiceMode == MessageMode.wordMorph) { + child = _MorphMatchButton( + active: _isSelected, + textColor: textColor, + onTap: () => controller.onSelectMorph( + MorphSelection( + token, + _activity!.morphFeature!, ), ), ); + } else { + child = _StandardMatchButton( + selectedChoice: controller.selectedChoice, + width: width, + borderColor: textColor, + onMatch: (choice) => _onMatch(choice), + ); } - } else { - return SizedBox(height: height); - } - } - if (MessageMode.wordMorph == messageMode) { - if (activity?.morphFeature == null) { - return SizedBox(height: height); - } - - return InkWell( - onHover: onHover, - onTap: onTap, - borderRadius: _borderRadius, - child: SizedBox( - height: height, - child: Opacity( - opacity: isSelected ? 1.0 : 0.6, - child: AnimatedBuilder( - animation: sizeAnimation, - builder: (context, child) { - return Icon( - Symbols.toys_and_games, - color: textColor, - size: sizeAnimation.value, // Use the new animation - ); - }, - ), + return AnimatedSize( + duration: const Duration( + milliseconds: AppConfig.overlayAnimationDuration, ), - ), - ); - } + curve: Curves.easeOut, + alignment: Alignment.bottomCenter, + child: _isEmpty + ? const SizedBox(height: 0) + : SizedBox(height: tokenButtonHeight, child: child), + ); + }, + ); + } +} +class _StandardMatchButton extends StatelessWidget { + final PracticeChoice? selectedChoice; + final double width; + final Color borderColor; + final Function(PracticeChoice choice) onMatch; + + const _StandardMatchButton({ + required this.selectedChoice, + required this.width, + required this.borderColor, + required this.onMatch, + }); + + @override + Widget build(BuildContext context) { return DragTarget( builder: (BuildContext context, accepted, rejected) { final double colorAlpha = 0.3 + @@ -377,40 +148,136 @@ class MessageTokenButtonContent extends StatelessWidget { (accepted.isNotEmpty ? 0.3 : 0.0); final theme = Theme.of(context); + final borderRadius = BorderRadius.circular(AppConfig.borderRadius - 4); - return InkWell( - onTap: selectedChoice != null - ? () => onMatch?.call(selectedChoice!) - : null, - borderRadius: _borderRadius, - child: CustomPaint( - painter: DottedBorderPainter( - color: textColor.withAlpha((colorAlpha * 255).toInt()), - borderRadius: _borderRadius, - ), - child: Shimmer.fromColors( - enabled: selectedChoice != null, - baseColor: selectedChoice != null - ? AppConfig.gold.withAlpha(20) - : Colors.transparent, - highlightColor: selectedChoice != null - ? AppConfig.gold.withAlpha(50) - : Colors.transparent, - child: Container( - height: height, - padding: const EdgeInsets.only(top: 10.0), - width: max(width, 24.0), - alignment: Alignment.center, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: _borderRadius, + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: + selectedChoice != null ? () => onMatch(selectedChoice!) : null, + borderRadius: borderRadius, + child: CustomPaint( + painter: DottedBorderPainter( + color: borderColor.withAlpha((colorAlpha * 255).toInt()), + borderRadius: borderRadius, + ), + child: Shimmer.fromColors( + enabled: selectedChoice != null, + baseColor: selectedChoice != null + ? AppConfig.gold.withAlpha(20) + : Colors.transparent, + highlightColor: selectedChoice != null + ? AppConfig.gold.withAlpha(50) + : Colors.transparent, + child: Container( + padding: const EdgeInsets.only(top: 10.0), + width: max(width, 24.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: borderRadius, + ), ), ), ), ), ); }, - onAcceptWithDetails: (details) => onMatch?.call(details.data), + onAcceptWithDetails: (details) => onMatch(details.data), ); } } + +class _MorphMatchButton extends StatelessWidget { + final Function()? onTap; + final bool active; + final Color textColor; + + const _MorphMatchButton({ + required this.active, + required this.textColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: HoverBuilder( + builder: (context, hovered) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppConfig.borderRadius - 4), + child: Opacity( + opacity: active ? 1.0 : 0.6, + child: AnimatedScale( + scale: hovered || active ? 1.25 : 1.0, + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + child: Icon( + Symbols.toys_and_games, + color: textColor, + size: 24.0, + ), + ), + ), + ); + }, + ), + ); + } +} + +class _NoActivityContentButton extends StatelessWidget { + final MessageMode practiceMode; + final PangeaToken token; + final PracticeTarget? target; + final TextStyle emojiStyle; + + const _NoActivityContentButton({ + required this.practiceMode, + required this.token, + required this.target, + required this.emojiStyle, + }); + + @override + Widget build(BuildContext context) { + if (practiceMode == MessageMode.wordEmoji) { + final displayEmoji = target?.record.responses + .firstWhereOrNull( + (res) => res.cId == token.vocabConstructID && res.isCorrect, + ) + ?.text ?? + token.vocabConstructID.userSetEmoji.firstOrNull ?? + ''; + return Text( + displayEmoji, + style: emojiStyle, + ); + } + if (practiceMode == MessageMode.wordMorph && target != null) { + final morphFeature = target!.morphFeature!; + final morphTag = token.morphIdByFeature(morphFeature); + if (morphTag != null) { + return Tooltip( + message: getGrammarCopy( + category: morphFeature.toShortString(), + lemma: morphTag.lemma, + context: context, + ), + child: SizedBox( + width: 24.0, + child: Center( + child: MorphIcon( + morphFeature: morphFeature, + morphTag: morphTag.lemma, + ), + ), + ), + ); + } + } + return const SizedBox(); + } +} diff --git a/lib/pangea/message_token_text/tokens_util.dart b/lib/pangea/message_token_text/tokens_util.dart index 550c48e49..35abb916c 100644 --- a/lib/pangea/message_token_text/tokens_util.dart +++ b/lib/pangea/message_token_text/tokens_util.dart @@ -99,7 +99,9 @@ class TokensUtil { final List newTokens = []; for (final token in tokens) { - if (!token.lemma.saveVocab || !token.isContentWord) continue; + if (!token.lemma.saveVocab || !token.vocabConstructID.isContentWord) { + continue; + } if (token.vocabConstruct.uses.isNotEmpty) continue; if (newTokens.any((t) => t == token.text)) continue; diff --git a/lib/pangea/practice_activities/activity_display_instructions_enum.dart b/lib/pangea/practice_activities/activity_display_instructions_enum.dart deleted file mode 100644 index 36dc530b5..000000000 --- a/lib/pangea/practice_activities/activity_display_instructions_enum.dart +++ /dev/null @@ -1,6 +0,0 @@ -enum ActivityDisplayInstructionsEnum { highlight, hide, nothing } - -extension ActivityDisplayInstructionsEnumExt - on ActivityDisplayInstructionsEnum { - String get string => toString().split('.').last; -} diff --git a/lib/pangea/practice_activities/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart index 681bcf5db..f94a544b4 100644 --- a/lib/pangea/practice_activities/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -11,23 +11,7 @@ enum ActivityTypeEnum { lemmaId, emoji, morphId, - messageMeaning, // TODO: Add to L10n -} - -extension ActivityTypeExtension on ActivityTypeEnum { - bool get hiddenType { - switch (this) { - case ActivityTypeEnum.wordMeaning: - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.lemmaId: - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.morphId: - case ActivityTypeEnum.messageMeaning: - return false; - case ActivityTypeEnum.hiddenWordListening: - return true; - } - } + messageMeaning; bool get includeTTSOnClick { switch (this) { @@ -172,25 +156,6 @@ extension ActivityTypeExtension on ActivityTypeEnum { } } - Widget? get contentChallengeWidget { - switch (this) { - case ActivityTypeEnum.wordMeaning: - return null; - case ActivityTypeEnum.wordFocusListening: - return null; - case ActivityTypeEnum.hiddenWordListening: - return null; - case ActivityTypeEnum.lemmaId: - return null; - case ActivityTypeEnum.emoji: - return null; - case ActivityTypeEnum.morphId: - return null; - case ActivityTypeEnum.messageMeaning: - return null; // TODO: Add to L10n - } - } - /// The minimum number of tokens in a message for this activity type to be available. /// Matching activities don't make sense for a single-word message. int get minTokensForMatchActivity { @@ -206,4 +171,11 @@ extension ActivityTypeExtension on ActivityTypeEnum { return 1; } } + + static List get practiceTypes => [ + ActivityTypeEnum.emoji, + ActivityTypeEnum.wordMeaning, + ActivityTypeEnum.wordFocusListening, + ActivityTypeEnum.morphId, + ]; } diff --git a/lib/pangea/practice_activities/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart index 500934cce..3354421c5 100644 --- a/lib/pangea/practice_activities/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -7,7 +7,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; class EmojiActivityGenerator { - Future get( + static Future get( MessageActivityRequest req, ) async { if (req.targetTokens.length <= 1) { @@ -17,7 +17,7 @@ class EmojiActivityGenerator { return _matchActivity(req); } - Future _matchActivity( + static Future _matchActivity( MessageActivityRequest req, ) async { final Map> matchInfo = {}; diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index 17d425b93..e7a1ab7bc 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -1,9 +1,7 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; -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/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; @@ -13,14 +11,13 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da import 'package:fluffychat/widgets/matrix.dart'; class LemmaActivityGenerator { - Future get( + static Future get( MessageActivityRequest req, - BuildContext context, ) async { debugger(when: kDebugMode && req.targetTokens.length != 1); final token = req.targetTokens.first; - final List choices = await lemmaActivityDistractors(token); + final choices = await _lemmaActivityDistractors(token); // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( @@ -29,16 +26,16 @@ class LemmaActivityGenerator { targetTokens: [token], langCode: req.userL2, multipleChoiceContent: MultipleChoiceActivity( - question: L10n.of(context).chooseBaseForm, choices: choices, - answers: [token.lemma.text], - spanDisplayDetails: null, + answers: {token.lemma.text}, ), ), ); } - Future> lemmaActivityDistractors(PangeaToken token) async { + static Future> _lemmaActivityDistractors( + PangeaToken token, + ) async { final List lemmas = MatrixState .pangeaController.getAnalytics.constructListModel .constructList(type: ConstructTypeEnum.vocab) @@ -58,32 +55,33 @@ class LemmaActivityGenerator { ..sort((a, b) => distances[a]!.compareTo(distances[b]!)); // Take the shortest 4 - final choices = sortedLemmas.take(4).toList(); + final choices = sortedLemmas.take(4).toSet(); if (choices.isEmpty) { - return [token.lemma.text]; + return {token.lemma.text}; } if (!choices.contains(token.lemma.text)) { choices.add(token.lemma.text); - choices.shuffle(); } return choices; } // isolate helper function - Map _computeDistancesInIsolate(Map params) { + static Map _computeDistancesInIsolate( + Map params, + ) { final List lemmas = params['lemmas']; final String target = params['target']; // Calculate Levenshtein distances final Map distances = {}; for (final lemma in lemmas) { - distances[lemma] = levenshteinDistanceSync(target, lemma); + distances[lemma] = _levenshteinDistanceSync(target, lemma); } return distances; } - int levenshteinDistanceSync(String s, String t) { + static int _levenshteinDistanceSync(String s, String t) { final int m = s.length; final int n = t.length; final List> dp = List.generate( diff --git a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index 44c0df5b1..dfe8515d9 100644 --- a/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -1,76 +1,14 @@ import 'dart:async'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.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/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; -import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart'; -import 'package:fluffychat/pangea/word_bank/vocab_request.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class LemmaMeaningActivityGenerator { - Future get( - MessageActivityRequest req, - ) async { - if (req.targetTokens.length == 1) { - return _multipleChoiceActivity(req); - } else { - return _matchActivity(req); - } - } - - Future _multipleChoiceActivity( - MessageActivityRequest req, - ) async { - final ConstructIdentifier lemmaId = ConstructIdentifier( - lemma: req.targetTokens[0].lemma.text.isNotEmpty - ? req.targetTokens[0].lemma.text - : req.targetTokens[0].lemma.form, - type: ConstructTypeEnum.vocab, - category: req.targetTokens[0].pos, - ); - - final lemmaDefReq = LemmaInfoRequest( - lemma: lemmaId.lemma, - partOfSpeech: lemmaId.category, - lemmaLang: req.userL2, - userL1: req.userL1, - ); - - final res = await LemmaInfoRepo.get(lemmaDefReq); - - final choices = await getDistractorMeanings(lemmaDefReq, 3); - - if (!choices.contains(res.meaning)) { - choices.add(res.meaning); - choices.shuffle(); - } - - return MessageActivityResponse( - activity: PracticeActivityModel( - targetTokens: req.targetTokens, - langCode: req.userL2, - activityType: ActivityTypeEnum.wordMeaning, - multipleChoiceContent: MultipleChoiceActivity( - question: L10n.of(MatrixState.pangeaController.matrixState.context) - .whatIsMeaning(lemmaId.lemma, lemmaId.category), - choices: choices, - answers: [res.meaning], - spanDisplayDetails: null, - ), - ), - ); - } - - Future _matchActivity( + static Future get( MessageActivityRequest req, ) async { final List> lemmaInfoFutures = req.targetTokens @@ -96,46 +34,4 @@ class LemmaMeaningActivityGenerator { ), ); } - - /// From the cache, get a random set of cached definitions that are not for a specific lemma - static Future> getDistractorMeanings( - LemmaInfoRequest req, - int count, - ) async { - final eligible = await VocabRepo.getSemanticallySimilarWords( - VocabRequest( - langCode: req.lemmaLang, - level: MatrixState - .pangeaController.userController.profile.userSettings.cefrLevel, - lemma: req.lemma, - pos: req.partOfSpeech, - count: count, - ), - ); - eligible.vocab.shuffle(); - - final List distractorConstructUses = - eligible.vocab.take(count).toList(); - - final List> futureDefs = []; - for (final construct in distractorConstructUses) { - futureDefs.add( - LemmaInfoRepo.get( - LemmaInfoRequest( - lemma: construct.lemma, - partOfSpeech: construct.category, - lemmaLang: req.lemmaLang, - userL1: req.userL1, - ), - ), - ); - } - - final Set distractorDefs = {}; - for (final def in await Future.wait(futureDefs)) { - distractorDefs.add(def.meaning); - } - - return distractorDefs.toList(); - } } diff --git a/lib/pangea/practice_activities/morph_activity_generator.dart b/lib/pangea/practice_activities/morph_activity_generator.dart index ed5239235..aff0eec88 100644 --- a/lib/pangea/practice_activities/morph_activity_generator.dart +++ b/lib/pangea/practice_activities/morph_activity_generator.dart @@ -2,14 +2,12 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; -import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_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/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'; typedef MorphActivitySequence = Map; @@ -17,9 +15,9 @@ typedef POSActivitySequence = List; class MorphActivityGenerator { /// Generate a morphological activity for a given token and morphological feature - Future get( + static MessageActivityResponse get( MessageActivityRequest req, - ) async { + ) { debugger(when: kDebugMode && req.targetTokens.length != 1); debugger(when: kDebugMode && req.targetMorphFeature == null); @@ -34,8 +32,8 @@ class MorphActivityGenerator { throw "No morph tag found for morph feature"; } - final List distractors = - token.morphActivityDistractors(morphFeature, morphTag); + final distractors = token.morphActivityDistractors(morphFeature, morphTag); + distractors.add(morphTag); debugger(when: kDebugMode && distractors.length < 3); @@ -46,18 +44,8 @@ class MorphActivityGenerator { activityType: ActivityTypeEnum.morphId, morphFeature: req.targetMorphFeature, multipleChoiceContent: MultipleChoiceActivity( - question: MatrixState.pangeaController.matrixState.context.mounted - ? L10n.of(MatrixState.pangeaController.matrixState.context) - .whatIsTheMorphTag( - morphFeature.getDisplayCopy( - MatrixState.pangeaController.matrixState.context, - ), - token.text.content, - ) - : morphFeature.name, - choices: distractors + [morphTag], - answers: [morphTag], - spanDisplayDetails: null, + choices: distractors, + answers: {morphTag}, ), ), ); diff --git a/lib/pangea/practice_activities/multiple_choice_activity_model.dart b/lib/pangea/practice_activities/multiple_choice_activity_model.dart index 91d123acf..a381b1d3f 100644 --- a/lib/pangea/practice_activities/multiple_choice_activity_model.dart +++ b/lib/pangea/practice_activities/multiple_choice_activity_model.dart @@ -1,62 +1,25 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/practice_activities/relevant_span_display_details.dart'; class MultipleChoiceActivity { - final String question; - /// choices, including the correct answer - final List choices; - final List answers; - final RelevantSpanDisplayDetails? spanDisplayDetails; + final Set choices; + final Set answers; MultipleChoiceActivity({ - required this.question, required this.choices, required this.answers, - required this.spanDisplayDetails, }); - /// we've had some bugs where the index is not expected - /// so we're going to check if the index or the value is correct - /// and if not, we'll investigate - bool isCorrect(String value, int index) { - if (value != choices[index]) { - debugger(when: kDebugMode); - } - return answers.contains(value) || correctAnswerIndices.contains(index); - } + Color choiceColor(String value) => + answers.contains(value) ? AppConfig.success : AppConfig.warning; - bool get isValidQuestion => choices.toSet().containsAll(answers); - - List get correctAnswerIndices { - final List indices = []; - for (var i = 0; i < choices.length; i++) { - if (answers.contains(choices[i])) { - indices.add(i); - } - } - return indices; - } - - int choiceIndex(String choice) => choices.indexOf(choice); - - Color choiceColor(int index) => correctAnswerIndices.contains(index) - ? AppConfig.success - : AppConfig.warning; + bool isCorrect(String value) => answers.contains(value); factory MultipleChoiceActivity.fromJson(Map json) { - final spanDisplay = json['span_display_details'] != null && - json['span_display_details'] is Map - ? RelevantSpanDisplayDetails.fromJson(json['span_display_details']) - : null; - final answerEntry = json['answer'] ?? json['correct_answer'] ?? ""; List answers = []; if (answerEntry is String) { @@ -66,19 +29,15 @@ class MultipleChoiceActivity { } return MultipleChoiceActivity( - question: json['question'] as String, - choices: (json['choices'] as List).map((e) => e as String).toList(), - answers: answers, - spanDisplayDetails: spanDisplay, + choices: (json['choices'] as List).map((e) => e as String).toSet(), + answers: answers.toSet(), ); } Map toJson() { return { - 'question': question, 'choices': choices, 'answer': answers, - 'span_display_details': spanDisplayDetails?.toJson(), }; } @@ -88,13 +47,12 @@ class MultipleChoiceActivity { if (identical(this, other)) return true; return other is MultipleChoiceActivity && - other.question == question && other.choices == choices && const ListEquality().equals(other.answers.sorted(), answers.sorted()); } @override int get hashCode { - return question.hashCode ^ choices.hashCode ^ Object.hashAll(answers); + return choices.hashCode ^ Object.hashAll(answers); } } diff --git a/lib/pangea/practice_activities/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart index 5844f60f9..c9b047de3 100644 --- a/lib/pangea/practice_activities/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -1,32 +1,21 @@ import 'dart:developer'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:sentry_flutter/sentry_flutter.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_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.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/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_match.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/practice_activities/relevant_span_display_details.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class PracticeActivityModel { - List targetTokens; + final List targetTokens; final ActivityTypeEnum activityType; final MorphFeaturesEnum? morphFeature; @@ -57,13 +46,15 @@ class PracticeActivityModel { } } - bool get isComplete => practiceTarget.isComplete; + PracticeTarget get practiceTarget => PracticeTarget( + tokens: targetTokens, + activityType: activityType, + morphFeature: morphFeature, + ); - void onMultipleChoiceSelect( + bool onMultipleChoiceSelect( PangeaToken token, PracticeChoice choice, - PangeaMessageEvent? event, - void Function() callback, ) { if (multipleChoiceContent == null) { debugger(when: kDebugMode); @@ -72,23 +63,21 @@ class PracticeActivityModel { s: StackTrace.current, data: toJson(), ); - return; + return false; } - // final ConstructIdentifier? cId = activityType == ActivityTypeEnum.morphId - // ? morphFeature ?= null ? token.getMorphTag(morphFeature) : null - // : choice.form.cId; - - if (practiceTarget.record.hasTextResponse(choice.choiceContent) || - isComplete) { + if (practiceTarget.isComplete || + practiceTarget.record.alreadyHasMatchResponse( + choice.form.cId, + choice.choiceContent, + )) { // the user has already selected this choice // so we don't want to record it again - return; + return false; } - final bool isCorrect = multipleChoiceContent!.answers.any( - (answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(), - ); + final bool isCorrect = + multipleChoiceContent!.isCorrect(choice.choiceContent); // NOTE: the response is associated with the contructId of the choice, not the selected token // example: the user selects the word "cat" to match with the emoji 🐶 @@ -100,53 +89,21 @@ class PracticeActivityModel { score: isCorrect ? 1 : 0, ); - // debugPrint( - // "onMultipleChoiceSelect: ${choice.form} ${responseUseType(choice)}", - // ); - - final constructUseType = - practiceTarget.record.responses.last.useType(activityType); - MatrixState.pangeaController.putAnalytics.setState( - AnalyticsStream( - eventId: event?.eventId, - roomId: event?.room.id, - constructs: [ - OneConstructUse( - useType: constructUseType, - lemma: choice.form.cId.lemma, - constructType: choice.form.cId.type, - metadata: ConstructUseMetaData( - roomId: event?.room.id, - timeStamp: DateTime.now(), - eventId: event?.eventId, - ), - category: choice.form.cId.category, - form: choice.form.form, - xp: constructUseType.pointValue, - ), - ], - targetID: targetTokens.first.text.uniqueKey, - ), - ); - - callback(); + return isCorrect; } - /// only set up for vocab constructs atm - void onMatch( + bool onMatch( PangeaToken token, PracticeChoice choice, - PangeaMessageEvent? event, - void Function() callback, ) { // the user has already selected this choice // so we don't want to record it again - if (practiceTarget.record.alreadyHasMatchResponse( + if (practiceTarget.isComplete || + practiceTarget.record.alreadyHasMatchResponse( token.vocabConstructID, choice.choiceContent, - ) || - isComplete) { - return; + )) { + return false; } bool isCorrect = false; @@ -154,21 +111,13 @@ class PracticeActivityModel { isCorrect = multipleChoiceContent!.answers.any( (answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(), ); - } else if (matchContent != null) { + } else { // we check to see if it's in the list of acceptable answers // rather than if the vocabForm is the same because an emoji // could be in multiple constructs so there could be multiple answers final answers = matchContent!.matchInfo[token.vocabForm]; debugger(when: answers == null && kDebugMode); isCorrect = answers!.contains(choice.choiceContent); - } else { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "in onMatch with null matchContent and multipleChoiceContent", - s: StackTrace.current, - data: toJson(), - ); - return; } // NOTE: the response is associated with the contructId of the selected token, not the choice @@ -176,100 +125,12 @@ class PracticeActivityModel { // the response is associated with incorrect word "cat", not the word "dog" practiceTarget.record.addResponse( cId: token.vocabConstructID, - text: choice.choiceContent, target: practiceTarget, + text: choice.choiceContent, score: isCorrect ? 1 : 0, ); - // we don't take off points for incorrect emoji matches - if (ActivityTypeEnum.emoji != activityType || isCorrect) { - final constructUseType = - practiceTarget.record.responses.last.useType(activityType); - MatrixState.pangeaController.putAnalytics.setState( - AnalyticsStream( - eventId: event?.eventId, - roomId: event?.room.id, - constructs: [ - OneConstructUse( - useType: constructUseType, - lemma: token.lemma.text, - constructType: ConstructTypeEnum.vocab, - metadata: ConstructUseMetaData( - roomId: event?.room.id, - timeStamp: DateTime.now(), - eventId: event?.eventId, - ), - category: token.pos, - // in the case of a wrong answer, the cId doesn't match the token - form: token.text.content, - xp: constructUseType.pointValue, - ), - ], - targetID: "message-token-${token.text.uniqueKey}-${event?.eventId}", - ), - ); - } - if (isCorrect) { - if (activityType == ActivityTypeEnum.emoji) { - choice.form.cId - .setEmojiWithXP( - emoji: choice.choiceContent, - isFromCorrectAnswer: true, - eventId: event?.eventId, - roomId: event?.room.id, - ) - .then((value) { - callback(); - }); - } - - if (activityType == ActivityTypeEnum.wordMeaning) { - choice.form.cId - .setUserLemmaInfo(UserSetLemmaInfo(meaning: choice.choiceContent)) - .then((value) { - callback(); - }); - } - } - callback(); - } - - PracticeRecord get record => practiceTarget.record; - - PracticeTarget get practiceTarget => PracticeTarget( - tokens: targetTokens, - activityType: activityType, - userL2: langCode, - morphFeature: morphFeature, - ); - - String get targetLemma => targetTokens.first.lemma.text; - - String get partOfSpeech => targetTokens.first.pos; - - String get targetWordForm => targetTokens.first.text.content; - - /// we were setting the question copy on creation of the activity - /// but, in order to localize the question using the same system - /// as other copy, we should do it with context, when it is built - /// some types are doing this now, others should be migrated - String question(BuildContext context, MorphFeaturesEnum? morphFeature) { - switch (activityType) { - case ActivityTypeEnum.hiddenWordListening: - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.lemmaId: - case ActivityTypeEnum.messageMeaning: - return multipleChoiceContent?.question ?? "You can do it!"; - case ActivityTypeEnum.emoji: - return L10n.of(context).pickAnEmoji(targetLemma, partOfSpeech); - case ActivityTypeEnum.wordMeaning: - return L10n.of(context).whatIsMeaning(targetLemma, partOfSpeech); - case ActivityTypeEnum.morphId: - return L10n.of(context).whatIsTheMorphTag( - morphFeature!.getDisplayCopy(context), - targetWordForm, - ); - } + return isCorrect; } factory PracticeActivityModel.fromJson(Map json) { @@ -323,9 +184,6 @@ class PracticeActivityModel { ); } - RelevantSpanDisplayDetails? get relevantSpanDisplayDetails => - multipleChoiceContent?.spanDisplayDetails; - Map toJson() { return { 'lang_code': langCode, diff --git a/lib/pangea/practice_activities/practice_generation_repo.dart b/lib/pangea/practice_activities/practice_generation_repo.dart index d0c983e09..008461d2e 100644 --- a/lib/pangea/practice_activities/practice_generation_repo.dart +++ b/lib/pangea/practice_activities/practice_generation_repo.dart @@ -3,18 +3,15 @@ import 'dart:convert'; import 'dart:developer'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:async/async.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:http/http.dart'; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/common/config/environment.dart'; -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; -import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/practice_activities/emoji_activity_generator.dart'; import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart'; @@ -23,80 +20,69 @@ 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/toolbar/event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Represents an item in the completion cache. class _RequestCacheItem { - final MessageActivityRequest req; - final PracticeActivityModelResponse practiceActivity; - final DateTime createdAt = DateTime.now(); + final PracticeActivityModel practiceActivity; + final DateTime timestamp; _RequestCacheItem({ - required this.req, required this.practiceActivity, + required this.timestamp, }); + + bool get isExpired => + DateTime.now().difference(timestamp) > PracticeRepo._cacheDuration; + + factory _RequestCacheItem.fromJson(Map json) { + return _RequestCacheItem( + practiceActivity: + PracticeActivityModel.fromJson(json['practiceActivity']), + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() => { + 'practiceActivity': practiceActivity.toJson(), + 'timestamp': timestamp.toIso8601String(), + }; } /// Controller for handling activity completions. class PracticeRepo { - static final Map _cache = {}; - Timer? _cacheClearTimer; + static final GetStorage _storage = GetStorage('practice_activity_cache'); + static const Duration _cacheDuration = Duration(minutes: 1); - late PangeaController _pangeaController; - - final _morph = MorphActivityGenerator(); - final _emoji = EmojiActivityGenerator(); - final _lemma = LemmaActivityGenerator(); - final _wordFocusListening = WordFocusListeningGenerator(); - final _wordMeaning = LemmaMeaningActivityGenerator(); - - PracticeRepo() { - _pangeaController = MatrixState.pangeaController; - _initializeCacheClearing(); - } - - void _initializeCacheClearing() { - const duration = Duration(minutes: 10); - _cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache()); - } - - void _clearCache() { - final now = DateTime.now(); - final keys = _cache.keys.toList(); - for (final key in keys) { - final item = _cache[key]!; - if (now.difference(item.createdAt) > const Duration(minutes: 10)) { - _cache.remove(key); - } - } - } - - void dispose() { - _cacheClearTimer?.cancel(); - } - - Future _sendAndPackageEvent( - PracticeActivityModel model, - PangeaMessageEvent pangeaMessageEvent, + /// [event] is optional and used for saving the activity event to Matrix + static Future> getPracticeActivity( + MessageActivityRequest req, ) async { - final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent( - content: model.toJson(), - parentEventId: pangeaMessageEvent.eventId, - type: PangeaEventTypes.pangeaActivity, - ); + final cached = _getCached(req); + if (cached != null) return Result.value(cached); - if (activityEvent == null) { - return null; + try { + final MessageActivityResponse res = await _routePracticeActivity( + accessToken: MatrixState.pangeaController.userController.accessToken, + req: req, + ); + + _setCached(req, res); + return Result.value(res.activity); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'message': 'Error fetching practice activity', + 'request': req.toJson(), + }, + ); + return Result.error(e, s); } - - return PracticeActivityEvent( - event: activityEvent, - timeline: pangeaMessageEvent.timeline, - ); } - Future _fetchFromServer({ + static Future _fetch({ required String accessToken, required MessageActivityRequest requestModel, }) async { @@ -109,96 +95,76 @@ class PracticeRepo { body: requestModel.toJson(), ); - if (res.statusCode == 200) { - final Map json = jsonDecode(utf8.decode(res.bodyBytes)); - - final response = MessageActivityResponse.fromJson(json); - - return response; - } else { - debugger(when: kDebugMode); - throw Exception('Failed to create activity'); + if (res.statusCode != 200) { + throw Exception('Failed to fetch activity'); } + + final Map json = jsonDecode(utf8.decode(res.bodyBytes)); + return MessageActivityResponse.fromJson(json); } - Future _routePracticeActivity({ + static Future _routePracticeActivity({ required String accessToken, required MessageActivityRequest req, - required BuildContext context, }) async { // some activities we'll get from the server and others we'll generate locally switch (req.targetType) { case ActivityTypeEnum.emoji: - return _emoji.get(req); + return EmojiActivityGenerator.get(req); case ActivityTypeEnum.lemmaId: - return _lemma.get(req, context); + return LemmaActivityGenerator.get(req); case ActivityTypeEnum.morphId: - return _morph.get(req); + return MorphActivityGenerator.get(req); case ActivityTypeEnum.wordMeaning: debugger(when: kDebugMode); - return _wordMeaning.get(req); + return LemmaMeaningActivityGenerator.get(req); case ActivityTypeEnum.messageMeaning: case ActivityTypeEnum.wordFocusListening: - return _wordFocusListening.get(req); + return WordFocusListeningGenerator.get(req); case ActivityTypeEnum.hiddenWordListening: - return _fetchFromServer( + return _fetch( accessToken: accessToken, requestModel: req, ); } } - /// [event] is optional and used for saving the activity event to Matrix - Future getPracticeActivity( + static PracticeActivityModel? _getCached( MessageActivityRequest req, - PangeaMessageEvent? event, - BuildContext context, - ) async { - final int cacheKey = req.hashCode; - - if (_cache.containsKey(cacheKey)) { - return _cache[cacheKey]!.practiceActivity; + ) { + final keys = List.from(_storage.getKeys()); + for (final k in keys) { + try { + final item = _RequestCacheItem.fromJson(_storage.read(k)); + if (item.isExpired) { + _storage.remove(k); + } + } catch (e) { + _storage.remove(k); + } } - final MessageActivityResponse res = await _routePracticeActivity( - accessToken: _pangeaController.userController.accessToken, - req: req, - context: context, - ); - - // this improves the UI by generally packing wrapped choices more tightly - res.activity.multipleChoiceContent?.choices - .sort((a, b) => a.length.compareTo(b.length)); - - // TODO resolve some wierdness here whereby the activity can be null but then... it's not - final eventCompleter = Completer(); - - if (event != null) { - _sendAndPackageEvent(res.activity, event).then((event) { - eventCompleter.complete(event); - }); + try { + final entry = _RequestCacheItem.fromJson( + _storage.read(req.hashCode.toString()), + ); + return entry.practiceActivity; + } catch (e) { + _storage.remove(req.hashCode.toString()); } + return null; + } - final responseModel = PracticeActivityModelResponse( - activity: res.activity, - eventCompleter: eventCompleter, + static void _setCached( + MessageActivityRequest req, + MessageActivityResponse res, + ) { + _storage.write( + req.hashCode.toString(), + _RequestCacheItem( + practiceActivity: res.activity, + timestamp: DateTime.now(), + ).toJson(), ); - - _cache[cacheKey] = _RequestCacheItem( - req: req, - practiceActivity: responseModel, - ); - - return responseModel; } } - -class PracticeActivityModelResponse { - final PracticeActivityModel? activity; - final Completer eventCompleter; - - PracticeActivityModelResponse({ - required this.activity, - required this.eventCompleter, - }); -} diff --git a/lib/pangea/practice_activities/practice_match.dart b/lib/pangea/practice_activities/practice_match.dart index e65efb164..e621f6978 100644 --- a/lib/pangea/practice_activities/practice_match.dart +++ b/lib/pangea/practice_activities/practice_match.dart @@ -54,10 +54,6 @@ class PracticeMatchActivity { ); } - bool isCorrect(ConstructForm form, String value) { - return matchInfo[form]!.contains(value); - } - factory PracticeMatchActivity.fromJson(Map json) { final Map> matchInfo = {}; for (final constructJson in json['match_info']) { diff --git a/lib/pangea/practice_activities/practice_record.dart b/lib/pangea/practice_activities/practice_record.dart index 8d7c5dda4..61e8e2d55 100644 --- a/lib/pangea/practice_activities/practice_record.dart +++ b/lib/pangea/practice_activities/practice_record.dart @@ -7,25 +7,19 @@ import 'dart:developer'; import 'package:flutter/foundation.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/common/utils/error_handler.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_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; class PracticeRecord { - late DateTime createdAt; late List responses; PracticeRecord({ List? responses, DateTime? timestamp, }) { - createdAt = timestamp ?? DateTime.now(); if (responses == null) { this.responses = List.empty(growable: true); } else { @@ -51,7 +45,6 @@ class PracticeRecord { Map toJson() { return { 'responses': responses.map((e) => e.toJson()).toList(), - 'createdAt': createdAt.toIso8601String(), }; } @@ -68,87 +61,42 @@ class PracticeRecord { return responses[responses.length - 1]; } - bool hasTextResponse(String text) { - return responses.any((element) => element.text == text); - } - bool alreadyHasMatchResponse( ConstructIdentifier cId, String text, - ) { - return responses.any( - (element) => element.cId == cId && element.text == text, - ); - } + ) => + responses.any( + (element) => element.cId == cId && element.text == text, + ); /// [target] needed for saving the record, little funky /// [cId] identifies the construct in the case of match activities which have multiple /// [text] is the user's response - /// [audioBytes] is the user's audio response - /// [imageBytes] is the user's image response /// [score] > 0 means correct, otherwise is incorrect void addResponse({ required ConstructIdentifier cId, required PracticeTarget target, - String? text, - Uint8List? audioBytes, - Uint8List? imageBytes, + required String text, required double score, }) { - try { - if (text == null && audioBytes == null && imageBytes == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "No response data provided", - data: { - 'cId': cId.toJson(), - 'text': text, - 'audioBytes': audioBytes, - 'imageBytes': imageBytes, - 'score': score, - }, - ); - return; - } - responses.add( - ActivityRecordResponse( - cId: cId, - text: text, - audioBytes: audioBytes, - imageBytes: imageBytes, - timestamp: DateTime.now(), - score: score, - ), - ); - debugPrint("responses: ${responses.map((r) => r.toJson())}"); + responses.add( + ActivityRecordResponse( + cId: cId, + text: text, + audioBytes: null, + imageBytes: null, + timestamp: DateTime.now(), + score: score, + ), + ); - PracticeRecordRepo.save(target, this); + try { + PracticeRecordRepo.set(target, this); } catch (e) { debugger(when: kDebugMode); } } - void clearResponses() { - responses.clear(); - } - - /// Returns a list of [OneConstructUse] objects representing the uses of the practice activity. - /// - /// The [practiceActivity] parameter is the parent event, representing the activity itself. - /// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available. - /// - /// The method iterates over the [responses] to get [OneConstructUse] objects for each - List usesForAllResponses( - PracticeActivityModel practiceActivity, - ConstructUseMetaData metadata, - ) => - responses - .toSet() - .expand( - (response) => response.toUses(practiceActivity, metadata), - ) - .toList(); - @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -193,110 +141,6 @@ class ActivityRecordResponse { ConstructUseTypeEnum useType(ActivityTypeEnum aType) => isCorrect ? aType.correctUse : aType.incorrectUse; - // for each target construct create a OneConstructUse object - List toUses( - PracticeActivityModel practiceActivity, - ConstructUseMetaData metadata, - ) { - // if the emoji is already set, don't give points - // IMPORTANT: This assumes that scoring is happening before saving of the user's emoji choice. - if (practiceActivity.activityType == ActivityTypeEnum.emoji && - practiceActivity.targetTokens.first.getEmoji().isNotEmpty) { - return []; - } - - if (practiceActivity.targetTokens.isEmpty) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "null targetTokens in practice activity", - data: practiceActivity.toJson(), - ); - return []; - } - - switch (practiceActivity.activityType) { - case ActivityTypeEnum.emoji: - case ActivityTypeEnum.wordMeaning: - case ActivityTypeEnum.wordFocusListening: - case ActivityTypeEnum.lemmaId: - final token = practiceActivity.targetTokens.first; - final constructUseType = useType(practiceActivity.activityType); - return [ - OneConstructUse( - lemma: token.lemma.text, - form: token.text.content, - constructType: ConstructTypeEnum.vocab, - useType: constructUseType, - metadata: metadata, - category: token.pos, - xp: constructUseType.pointValue, - ), - ]; - case ActivityTypeEnum.messageMeaning: - final constructUseType = useType(practiceActivity.activityType); - return practiceActivity.targetTokens - .expand( - (t) => t.allUses( - constructUseType, - metadata, - constructUseType.pointValue, - ), - ) - .toList(); - case ActivityTypeEnum.hiddenWordListening: - final constructUseType = useType(practiceActivity.activityType); - return practiceActivity.targetTokens - .map( - (token) => OneConstructUse( - lemma: token.lemma.text, - form: token.text.content, - constructType: ConstructTypeEnum.vocab, - useType: constructUseType, - metadata: metadata, - category: token.pos, - xp: constructUseType.pointValue, - ), - ) - .toList(); - case ActivityTypeEnum.morphId: - if (practiceActivity.morphFeature == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "null morphFeature in morph activity", - data: practiceActivity.toJson(), - ); - return []; - } - return practiceActivity.targetTokens - .map( - (t) { - final tag = t.getMorphTag(practiceActivity.morphFeature!); - if (tag == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "null tag in morph activity", - data: practiceActivity.toJson(), - ); - return null; - } - final constructUseType = useType(practiceActivity.activityType); - return OneConstructUse( - lemma: tag, - form: practiceActivity.targetTokens.first.text.content, - constructType: ConstructTypeEnum.morph, - useType: constructUseType, - metadata: metadata, - category: practiceActivity.morphFeature!, - xp: constructUseType.pointValue, - ); - }, - ) - .where((c) => c != null) - .cast() - .toList(); - } - } - factory ActivityRecordResponse.fromJson(Map json) { return ActivityRecordResponse( cId: ConstructIdentifier.fromJson(json['cId'] as Map), diff --git a/lib/pangea/practice_activities/practice_record_repo.dart b/lib/pangea/practice_activities/practice_record_repo.dart index b6a84e342..431ceda4f 100644 --- a/lib/pangea/practice_activities/practice_record_repo.dart +++ b/lib/pangea/practice_activities/practice_record_repo.dart @@ -1,78 +1,61 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:get_storage/get_storage.dart'; - import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +class _PracticeRecordCacheEntry { + final PracticeRecord record; + final DateTime timestamp; + + _PracticeRecordCacheEntry({ + required this.record, + required this.timestamp, + }); + + bool get isExpired => DateTime.now().difference(timestamp).inMinutes > 15; +} + /// Controller for handling activity completions. class PracticeRecordRepo { - static final GetStorage _storage = GetStorage('practice_record_cache'); - static final Map _memoryCache = {}; - static const int _maxMemoryCacheSize = 50; - - void dispose() { - _storage.erase(); - _memoryCache.clear(); - } - - static void save( - PracticeTarget selection, - PracticeRecord entry, - ) { - _storage.write(selection.storageKey, entry.toJson()); - _memoryCache[selection.storageKey] = entry; - } - - static void clean() { - final keys = _storage.getKeys(); - if (keys.length > 300) { - final entries = keys - .map((key) { - final entry = PracticeRecord.fromJson(_storage.read(key)); - return MapEntry(key, entry); - }) - .cast>() - .toList() - ..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt)); - for (var i = 0; i < 5; i++) { - _storage.remove(entries[i].key); - } - } - if (_memoryCache.length > _maxMemoryCacheSize) { - _memoryCache.remove(_memoryCache.keys.first); - } - } + static final Map _cache = {}; static PracticeRecord get( PracticeTarget target, ) { - final String key = target.storageKey; - if (_memoryCache.containsKey(key)) { - return _memoryCache[key]!; - } + final cached = _getCached(target); + if (cached != null) return cached; - final entryJson = _storage.read(key); - if (entryJson != null) { - final entry = PracticeRecord.fromJson(entryJson); - if (DateTime.now().difference(entry.createdAt).inDays > 1) { - debugPrint('removing old entry ${entry.createdAt}'); - _storage.remove(key); - } else { - _memoryCache[key] = entry; - return entry; + final entry = PracticeRecord(); + _setCached(target, entry); + + return entry; + } + + static void set( + PracticeTarget selection, + PracticeRecord entry, + ) => + _setCached(selection, entry); + + static PracticeRecord? _getCached( + PracticeTarget target, + ) { + final keys = List.from(_cache.keys); + for (final k in keys) { + final item = _cache[k]!; + if (item.isExpired) { + _cache.remove(k); } } - debugPrint('creating new practice record for $key'); - final newEntry = PracticeRecord(); + return _cache[target.storageKey]?.record; + } - _storage.write(key, newEntry.toJson()); - _memoryCache[key] = newEntry; - - clean(); - - return newEntry; + static void _setCached( + PracticeTarget target, + PracticeRecord entry, + ) { + _cache[target.storageKey] = _PracticeRecordCacheEntry( + record: entry, + timestamp: DateTime.now(), + ); } } diff --git a/lib/pangea/practice_activities/practice_selection.dart b/lib/pangea/practice_activities/practice_selection.dart index d67555959..39a28c782 100644 --- a/lib/pangea/practice_activities/practice_selection.dart +++ b/lib/pangea/practice_activities/practice_selection.dart @@ -1,53 +1,31 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; -import 'package:fluffychat/pangea/lemmas/lemma_info_response.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_selection_repo.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class PracticeSelection { - late String _userL2; - final DateTime createdAt = DateTime.now(); + final Map> _activityQueue; + static const int maxQueueLength = 5; - late final List _tokens; + PracticeSelection(this._activityQueue); - final String langCode; + List activities(ActivityTypeEnum a) => + _activityQueue[a] ?? []; - final Map> _activityQueue = {}; + PracticeTarget? getTarget(ActivityTypeEnum type) => + activities(type).firstOrNull; - final int _maxQueueLength = 5; - - PracticeSelection({ - required List tokens, - required this.langCode, - String? userL1, - String? userL2, - }) { - _userL2 = userL2 ?? - MatrixState.pangeaController.languageController.userL2?.langCode ?? - LanguageKeys.defaultLanguage; - _tokens = tokens; - initialize(); - } - - List get tokens => _tokens; - - bool get eligibleForPractice => - _tokens.any((t) => t.lemma.saveVocab) && - langCode.split("-")[0] == _userL2.split("-")[0]; + PracticeTarget? getMorphTarget( + PangeaToken t, + MorphFeaturesEnum morph, + ) => + activities(ActivityTypeEnum.morphId).firstWhereOrNull( + (entry) => entry.tokens.contains(t) && entry.morphFeature == morph, + ); Map toJson() => { - 'createdAt': createdAt.toIso8601String(), - 'lang_code': langCode, - 'tokens': _tokens.map((t) => t.toJson()).toList(), 'activityQueue': _activityQueue.map( (key, value) => MapEntry( key.toString(), @@ -58,292 +36,12 @@ class PracticeSelection { static PracticeSelection fromJson(Map json) { return PracticeSelection( - langCode: json['lang_code'] as String, - tokens: - (json['tokens'] as List).map((t) => PangeaToken.fromJson(t)).toList(), - ).._activityQueue.addAll( - (json['activityQueue'] as Map).map( - (key, value) => MapEntry( - ActivityTypeEnum.values.firstWhere((e) => e.toString() == key), - (value as List).map((e) => PracticeTarget.fromJson(e)).toList(), - ), + (json['activityQueue'] as Map).map( + (key, value) => MapEntry( + ActivityTypeEnum.values.firstWhere((e) => e.toString() == key), + (value as List).map((e) => PracticeTarget.fromJson(e)).toList(), ), - ); - } - - void _pushQueue(PracticeTarget entry) { - if (_activityQueue.containsKey(entry.activityType)) { - _activityQueue[entry.activityType]!.insert(0, entry); - } else { - _activityQueue[entry.activityType] = [entry]; - } - - // just in case we make a mistake and the queue gets too long - if (_activityQueue[entry.activityType]!.length > _maxQueueLength) { - debugger(when: kDebugMode); - _activityQueue[entry.activityType]!.removeRange( - _maxQueueLength, - _activityQueue.length, - ); - } - } - - PracticeTarget? nextActivity(ActivityTypeEnum a) => - MatrixState.pangeaController.languageController.userL2?.langCode == - _userL2 - ? _activityQueue[a]?.firstOrNull - : null; - - bool get hasHiddenWordActivity => - activities(ActivityTypeEnum.hiddenWordListening).isNotEmpty; - - bool get hasMessageMeaningActivity => - activities(ActivityTypeEnum.messageMeaning).isNotEmpty; - - int get numActivities => _activityQueue.length; - - List activities(ActivityTypeEnum a) => - _activityQueue[a] ?? []; - - // /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening - // /// Otherwise, we don't have enough distractors - // bool get canDoWordFocusListening => - // _tokens.where((t) => t.canBeHeard).length > 4; - - bool tokenIsIncludedInActivityOfAnyType( - PangeaToken t, - ) { - return _activityQueue.entries.any( - (perActivityQueue) => perActivityQueue.value.any( - (entry) => entry.tokens.contains(t), ), ); } - - List buildActivity(ActivityTypeEnum activityType) { - if (!eligibleForPractice) { - return []; - } - - final List basicallyEligible = - _tokens.where((t) => t.lemma.saveVocab).toList(); - - // list of tokens with unique lemmas and surface forms - final List tokens = []; - for (final t in basicallyEligible) { - if (!tokens.any( - (token) => - token.lemma == t.lemma && token.text.content == t.text.content, - )) { - tokens.add(t); - } - } - - tokens.sort( - (a, b) { - final bScore = b.activityPriorityScore(activityType, null) * - (tokenIsIncludedInActivityOfAnyType(b) ? 1.1 : 1); - - final aScore = a.activityPriorityScore(activityType, null) * - (tokenIsIncludedInActivityOfAnyType(a) ? 1.1 : 1); - - return bScore.compareTo(aScore); - }, - ); - - if (tokens.isEmpty) { - return []; - } - - if (tokens.length < activityType.minTokensForMatchActivity) { - // if we only have one token, we don't need to do an emoji activity - return []; - } - - //remove duplicates - final seenTexts = {}; - final seemLemmas = {}; - tokens.retainWhere( - (token) => - seenTexts.add(token.text.content.toLowerCase()) && - seemLemmas.add(token.lemma.text.toLowerCase()), - ); - - if (tokens.length > 8) { - // Remove the last third (floored) of tokens, only greater than 8 items so at least 5 remain - final int removeCount = (tokens.length / 3).floor(); - final int keepCount = tokens.length - removeCount; - tokens.removeRange(keepCount, tokens.length); - } - - //shuffle leftover list so if there are enough, each activity gets different tokens - tokens.shuffle(); - - final List activityTokens = []; - for (final t in tokens) { - if (activityTokens.length >= _maxQueueLength) { - break; - } - activityTokens.add(t); - } - - return [ - PracticeTarget( - activityType: activityType, - tokens: activityTokens, - userL2: _userL2, - ), - ]; - } - - List buildMorphActivity() { - final eligibleTokens = _tokens.where((t) => t.lemma.saveVocab); - if (!eligibleForPractice) { - return []; - } - final List candidates = eligibleTokens.expand( - (t) { - return t.morphsBasicallyEligibleForPracticeByPriority.map( - (m) => PracticeTarget( - tokens: [t], - activityType: ActivityTypeEnum.morphId, - morphFeature: MorphFeaturesEnumExtension.fromString(m.category), - userL2: _userL2, - ), - ); - }, - ).sorted( - (a, b) { - final bScore = b.tokens.first.activityPriorityScore( - ActivityTypeEnum.morphId, - b.morphFeature!, - ) * - (tokenIsIncludedInActivityOfAnyType(b.tokens.first) ? 1.1 : 1); - - final aScore = a.tokens.first.activityPriorityScore( - ActivityTypeEnum.morphId, - a.morphFeature!, - ) * - (tokenIsIncludedInActivityOfAnyType(a.tokens.first) ? 1.1 : 1); - - return bScore.compareTo(aScore); - }, - ); - //pick from the top 5, only including one per token - final List finalSelection = []; - for (final candidate in candidates) { - if (finalSelection.length >= _maxQueueLength) { - break; - } - if (finalSelection.any( - (entry) => entry.tokens.contains(candidate.tokens.first), - ) == - false) { - finalSelection.add(candidate); - } - } - return finalSelection; - } - - /// On initialization, we pick which tokens to do activities on and what types of activities to do - void initialize() { - // EMOJI - // sort the tokens by the preference of them for an emoji activity - // order from least to most recent - // words that have never been used are counted as 1000 days - // we preference content words over function words by multiplying the days since last use by 2 - // NOTE: for now, we put it at the end if it has no uses and basically just give them the answer - // later on, we may introduce an emoji activity that is easier than the current matching one - // i.e. we show them 3 good emojis and 1 bad one and ask them to pick the bad one - _activityQueue[ActivityTypeEnum.emoji] = - buildActivity(ActivityTypeEnum.emoji); - - // WORD MEANING - // make word meaning activities - // same as emojis for now - _activityQueue[ActivityTypeEnum.wordMeaning] = - buildActivity(ActivityTypeEnum.wordMeaning); - - // WORD FOCUS LISTENING - // make word focus listening activities - // same as emojis for now - _activityQueue[ActivityTypeEnum.wordFocusListening] = - buildActivity(ActivityTypeEnum.wordFocusListening); - - // GRAMMAR - // build a list of TargetTokensAndActivityType for all tokens and all features in the message - // limits to _maxQueueLength activities and only one per token - _activityQueue[ActivityTypeEnum.morphId] = buildMorphActivity(); - - PracticeSelectionRepo.save(this); - } - - PracticeTarget? getSelection( - ActivityTypeEnum a, [ - PangeaToken? t, - MorphFeaturesEnum? morph, - ]) { - if (a == ActivityTypeEnum.morphId && (t == null || morph == null)) { - return null; - } - return activities(a).firstWhereOrNull( - (entry) => - (t == null || entry.tokens.contains(t)) && - (morph == null || entry.morphFeature == morph), - ); - } - - bool hasActiveActivityByToken( - ActivityTypeEnum a, - PangeaToken t, [ - MorphFeaturesEnum? morph, - ]) => - getSelection(a, t, morph)?.isCompleteByToken(t, morph) == false; - - /// Add a message meaning activity to the front of the queue - /// And limits to _maxQueueLength activities - void addMessageMeaningActivity() { - final entry = PracticeTarget( - tokens: _tokens, - activityType: ActivityTypeEnum.messageMeaning, - userL2: _userL2, - ); - _pushQueue(entry); - } - - void exitPracticeFlow() { - _activityQueue.clear(); - PracticeSelectionRepo.save(this); - } - - void revealAllTokens() { - _activityQueue[ActivityTypeEnum.hiddenWordListening]?.clear(); - PracticeSelectionRepo.save(this); - } - - bool isTokenInHiddenWordActivity(PangeaToken token) => - _activityQueue[ActivityTypeEnum.hiddenWordListening]?.isNotEmpty ?? false; - - Future> getLemmaInfoForActivityTokens() async { - // make a list of unique tokens in emoji and wordMeaning activities - final List uniqueTokens = []; - for (final t in _activityQueue[ActivityTypeEnum.emoji] ?? []) { - if (!uniqueTokens.contains(t.tokens.first)) { - uniqueTokens.add(t.tokens.first); - } - } - for (final t in _activityQueue[ActivityTypeEnum.wordMeaning] ?? []) { - if (!uniqueTokens.contains(t.tokens.first)) { - uniqueTokens.add(t.tokens.first); - } - } - - // get the lemma info for each token - final List> lemmaInfoFutures = []; - for (final t in uniqueTokens) { - lemmaInfoFutures.add(t.vocabConstructID.getLemmaInfo()); - } - - return Future.wait(lemmaInfoFutures); - } } diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index fc97ed381..ff7c5287f 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -1,112 +1,211 @@ -import 'package:flutter/material.dart'; - import 'package:get_storage/get_storage.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_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_selection.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/widgets/matrix.dart'; +class _PracticeSelectionCacheEntry { + final PracticeSelection selection; + final DateTime timestamp; + + _PracticeSelectionCacheEntry({ + required this.selection, + required this.timestamp, + }); + + bool get isExpired => DateTime.now().difference(timestamp).inDays > 1; + + Map toJson() => { + 'selection': selection.toJson(), + 'timestamp': timestamp.toIso8601String(), + }; + + factory _PracticeSelectionCacheEntry.fromJson(Map json) { + return _PracticeSelectionCacheEntry( + selection: PracticeSelection.fromJson(json['selection']), + timestamp: DateTime.parse(json['timestamp']), + ); + } +} + class PracticeSelectionRepo { static final GetStorage _storage = GetStorage('practice_selection_cache'); - static final Map _memoryCache = {}; - static const int _maxMemoryCacheSize = 50; - - void dispose() { - _storage.erase(); - _memoryCache.clear(); - } - - static void save(PracticeSelection entry) { - final key = _key(entry.tokens); - _storage.write(key, entry.toJson()); - _memoryCache[key] = entry; - } - - static MapEntry? _parsePracticeSelection( - String key, - ) { - if (!_storage.hasData(key)) { - return null; - } - try { - final entry = PracticeSelection.fromJson(_storage.read(key)); - return MapEntry(key, entry); - } catch (e, s) { - ErrorHandler.logError( - m: 'Failed to parse PracticeSelection from JSON', - e: e, - s: s, - data: { - 'key': key, - 'json': _storage.read(key), - }, - ); - _storage.remove(key); - return null; - } - } - - static void clean() { - final keys = _storage.getKeys(); - if (keys.length > 300) { - final entries = keys - .map((key) => _parsePracticeSelection(key)) - .where((entry) => entry != null) - .cast>() - .toList() - ..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt)); - for (var i = 0; i < 5 && i < entries.length; i++) { - _storage.remove(entries[i].key); - } - } - if (_memoryCache.length > _maxMemoryCacheSize) { - _memoryCache.remove(_memoryCache.keys.first); - } - } - - static String _key(List tokens) => - tokens.map((t) => t.text.content).join(' '); static PracticeSelection? get( + String eventId, String messageLanguage, List tokens, ) { final userL2 = MatrixState.pangeaController.languageController.userL2; - final String key = _key(tokens); - if (_memoryCache.containsKey(key)) { - final entry = _memoryCache[key]; - return entry?.langCode.split("-").first == userL2?.langCodeShort - ? entry - : null; + if (userL2?.langCodeShort != messageLanguage.split("-").first) { + return null; } - final stored = _parsePracticeSelection(key); - if (stored != null) { - final entry = stored.value; - if (DateTime.now().difference(entry.createdAt).inDays > 1) { - debugPrint('removing old entry ${entry.createdAt}'); + final cached = _getCached(eventId); + if (cached != null) return cached; + + final newEntry = _fetch( + tokens: tokens, + langCode: messageLanguage, + ); + + _setCached(eventId, newEntry); + return newEntry; + } + + static PracticeSelection _fetch({ + required List tokens, + required String langCode, + }) { + if (langCode.split("-")[0] != + MatrixState.pangeaController.languageController.userL2?.langCodeShort) { + return PracticeSelection({}); + } + + final eligibleTokens = tokens.where((t) => t.lemma.saveVocab).toList(); + if (eligibleTokens.isEmpty) { + return PracticeSelection({}); + } + final queue = _fillActivityQueue(eligibleTokens); + final selection = PracticeSelection(queue); + return selection; + } + + static PracticeSelection? _getCached( + String eventId, + ) { + for (final String key in _storage.getKeys()) { + try { + final cacheEntry = _PracticeSelectionCacheEntry.fromJson( + _storage.read(key), + ); + if (cacheEntry.isExpired) { + _storage.remove(key); + } + } catch (e) { _storage.remove(key); - } else { - _memoryCache[key] = entry; - return entry.langCode.split("-").first == userL2?.langCodeShort - ? entry - : null; } } - final newEntry = PracticeSelection( - langCode: messageLanguage, - tokens: tokens, + final entry = _storage.read(eventId); + if (entry == null) return null; + + try { + return _PracticeSelectionCacheEntry.fromJson( + _storage.read(eventId), + ).selection; + } catch (e) { + _storage.remove(eventId); + return null; + } + } + + static void _setCached( + String eventId, + PracticeSelection entry, + ) { + final cachedEntry = _PracticeSelectionCacheEntry( + selection: entry, + timestamp: DateTime.now(), + ); + _storage.write(eventId, cachedEntry.toJson()); + } + + static Map> _fillActivityQueue( + List tokens, + ) { + final queue = >{}; + for (final type in ActivityTypeEnum.practiceTypes) { + queue[type] = _buildActivity(type, tokens); + } + return queue; + } + + static int _sortTokens( + PangeaToken a, + PangeaToken b, + ActivityTypeEnum activityType, + ) { + final bScore = b.activityPriorityScore(activityType, null); + final aScore = a.activityPriorityScore(activityType, null); + return bScore.compareTo(aScore); + } + + static int _sortMorphTargets(PracticeTarget a, PracticeTarget b) { + final bScore = b.tokens.first.activityPriorityScore( + ActivityTypeEnum.morphId, + b.morphFeature!, ); - _storage.write(key, newEntry.toJson()); - _memoryCache[key] = newEntry; + final aScore = a.tokens.first.activityPriorityScore( + ActivityTypeEnum.morphId, + a.morphFeature!, + ); - clean(); + return bScore.compareTo(aScore); + } - return newEntry.langCode.split("-").first == userL2?.langCodeShort - ? newEntry - : null; + static List _tokenToMorphTargets(PangeaToken t) { + return t.morphsBasicallyEligibleForPracticeByPriority + .map( + (m) => PracticeTarget( + tokens: [t], + activityType: ActivityTypeEnum.morphId, + morphFeature: MorphFeaturesEnumExtension.fromString(m.category), + ), + ) + .toList(); + } + + static List _buildActivity( + ActivityTypeEnum activityType, + List tokens, + ) { + if (activityType == ActivityTypeEnum.morphId) { + return _buildMorphActivity(tokens); + } + + List practiceTokens = List.from(tokens); + final seenTexts = {}; + final seenLemmas = {}; + practiceTokens.retainWhere( + (token) => + token.eligibleForPractice(activityType) && + seenTexts.add(token.text.content.toLowerCase()) && + seenLemmas.add(token.lemma.text.toLowerCase()), + ); + + if (practiceTokens.length < activityType.minTokensForMatchActivity) { + return []; + } + + practiceTokens.sort((a, b) => _sortTokens(a, b, activityType)); + practiceTokens = practiceTokens.take(8).toList(); + practiceTokens.shuffle(); + + return [ + PracticeTarget( + activityType: activityType, + tokens: practiceTokens.take(PracticeSelection.maxQueueLength).toList(), + ), + ]; + } + + static List _buildMorphActivity(List tokens) { + final List practiceTokens = List.from(tokens); + final candidates = practiceTokens.expand(_tokenToMorphTargets).toList(); + candidates.sort(_sortMorphTargets); + + final seenTexts = {}; + final seenLemmas = {}; + candidates.retainWhere( + (target) => + seenTexts.add(target.tokens.first.text.content.toLowerCase()) && + seenLemmas.add(target.tokens.first.lemma.text.toLowerCase()), + ); + return candidates.take(PracticeSelection.maxQueueLength).toList(); } } diff --git a/lib/pangea/practice_activities/practice_target.dart b/lib/pangea/practice_activities/practice_target.dart index fbb711f2a..4b573a9bf 100644 --- a/lib/pangea/practice_activities/practice_target.dart +++ b/lib/pangea/practice_activities/practice_target.dart @@ -30,12 +30,9 @@ class PracticeTarget { /// this is only defined for morphId activities final MorphFeaturesEnum? morphFeature; - final String userL2; - PracticeTarget({ required this.tokens, required this.activityType, - required this.userL2, this.morphFeature, }) { if (ActivityTypeEnum.morphId == activityType && morphFeature == null) { @@ -50,16 +47,12 @@ class PracticeTarget { return other is PracticeTarget && listEquals(other.tokens, tokens) && other.activityType == activityType && - other.morphFeature == morphFeature && - other.userL2 == userL2; + other.morphFeature == morphFeature; } @override int get hashCode => - tokens.hashCode ^ - activityType.hashCode ^ - morphFeature.hashCode ^ - userL2.hashCode; + tokens.hashCode ^ activityType.hashCode ^ morphFeature.hashCode; static PracticeTarget fromJson(Map json) { final type = ActivityTypeEnum.values.firstWhereOrNull( @@ -78,7 +71,6 @@ class PracticeTarget { morphFeature: json['morphFeature'] == null ? null : MorphFeaturesEnumExtension.fromString(json['morphFeature']), - userL2: json['userL2'], ); } @@ -87,7 +79,6 @@ class PracticeTarget { 'tokens': tokens.map((e) => e.toJson()).toList(), 'activityType': activityType.name, 'morphFeature': morphFeature?.name, - 'userL2': userL2, }; } diff --git a/lib/pangea/practice_activities/relevant_span_display_details.dart b/lib/pangea/practice_activities/relevant_span_display_details.dart deleted file mode 100644 index 7d2a4d2cc..000000000 --- a/lib/pangea/practice_activities/relevant_span_display_details.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - -import 'package:collection/collection.dart'; - -import 'package:fluffychat/pangea/practice_activities/activity_display_instructions_enum.dart'; - -/// For those activities with a relevant span, this class will hold the details -/// of the span and how it should be displayed -/// e.g. hide the span for conjugation activities -class RelevantSpanDisplayDetails { - final int offset; - final int length; - final ActivityDisplayInstructionsEnum displayInstructions; - - RelevantSpanDisplayDetails({ - required this.offset, - required this.length, - required this.displayInstructions, - }); - - factory RelevantSpanDisplayDetails.fromJson(Map json) { - final ActivityDisplayInstructionsEnum? display = - ActivityDisplayInstructionsEnum.values.firstWhereOrNull( - (e) => e.string == json['display_instructions'], - ); - if (display == null) { - debugger(when: kDebugMode); - } - return RelevantSpanDisplayDetails( - offset: json['offset'] as int, - length: json['length'] as int, - displayInstructions: display ?? ActivityDisplayInstructionsEnum.nothing, - ); - } - - Map toJson() { - return { - 'offset': offset, - 'length': length, - 'display_instructions': displayInstructions.string, - }; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is RelevantSpanDisplayDetails && - other.offset == offset && - other.length == length && - other.displayInstructions == displayInstructions; - } - - @override - int get hashCode { - return offset.hashCode ^ length.hashCode ^ displayInstructions.hashCode; - } -} diff --git a/lib/pangea/practice_activities/word_focus_listening_generator.dart b/lib/pangea/practice_activities/word_focus_listening_generator.dart index 2b76f32aa..3d03a37ba 100644 --- a/lib/pangea/practice_activities/word_focus_listening_generator.dart +++ b/lib/pangea/practice_activities/word_focus_listening_generator.dart @@ -1,30 +1,19 @@ -import 'package:flutter/foundation.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_form.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.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_match.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class WordFocusListeningGenerator { - Future get( + static MessageActivityResponse get( MessageActivityRequest req, - ) async { + ) { if (req.targetTokens.length <= 1) { throw Exception( "Word focus listening activity requires at least 2 tokens", ); } - return _matchActivity(req); - } - - Future _matchActivity( - MessageActivityRequest req, - ) async { return MessageActivityResponse( activity: PracticeActivityModel( activityType: ActivityTypeEnum.wordFocusListening, @@ -46,76 +35,4 @@ class WordFocusListeningGenerator { ), ); } - - Future> lemmaActivityDistractors(PangeaToken token) async { - final List lemmas = MatrixState - .pangeaController.getAnalytics.constructListModel - .constructList(type: ConstructTypeEnum.vocab) - .map((c) => c.lemma) - .toSet() - .toList(); - - // Offload computation to an isolate - final Map distances = - await compute(_computeDistancesInIsolate, { - 'lemmas': lemmas, - 'target': token.lemma.text, - }); - - // Sort lemmas by distance - final sortedLemmas = distances.keys.toList() - ..sort((a, b) => distances[a]!.compareTo(distances[b]!)); - - // Take the shortest 4 - final choices = sortedLemmas.take(4).toList(); - if (choices.isEmpty) { - return [token.lemma.text]; - } - - if (!choices.contains(token.lemma.text)) { - choices.add(token.lemma.text); - choices.shuffle(); - } - return choices; - } - - // isolate helper function - Map _computeDistancesInIsolate(Map params) { - final List lemmas = params['lemmas']; - final String target = params['target']; - - // Calculate Levenshtein distances - final Map distances = {}; - for (final lemma in lemmas) { - distances[lemma] = levenshteinDistanceSync(target, lemma); - } - return distances; - } - - int levenshteinDistanceSync(String s, String t) { - final int m = s.length; - final int n = t.length; - final List> dp = List.generate( - m + 1, - (_) => List.generate(n + 1, (_) => 0), - ); - - for (int i = 0; i <= m; i++) { - for (int j = 0; j <= n; j++) { - if (i == 0) { - dp[i][j] = j; - } else if (j == 0) { - dp[i][j] = i; - } else if (s[i - 1] == t[j - 1]) { - dp[i][j] = dp[i - 1][j - 1]; - } else { - dp[i][j] = 1 + - [dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]] - .reduce((a, b) => a < b ? a : b); - } - } - } - - return dp[m][n]; - } } diff --git a/lib/pangea/toolbar/enums/message_mode_enum.dart b/lib/pangea/toolbar/enums/message_mode_enum.dart index 9c80b5761..75958cfc2 100644 --- a/lib/pangea/toolbar/enums/message_mode_enum.dart +++ b/lib/pangea/toolbar/enums/message_mode_enum.dart @@ -1,51 +1,26 @@ import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; -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/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; enum MessageMode { - practiceActivity, - - wordZoom, wordEmoji, wordMeaning, wordMorph, - // wordZoomTextToSpeech, - // wordZoomSpeechToText, - - messageMeaning, listening, - messageSpeechToText, + noneSelected; - // message not selected - noneSelected, -} - -extension MessageModeExtension on MessageMode { IconData get icon { switch (this) { case MessageMode.listening: return Icons.volume_up; - case MessageMode.messageSpeechToText: - return Symbols.speech_to_text; - case MessageMode.practiceActivity: - return Symbols.fitness_center; - case MessageMode.wordZoom: case MessageMode.wordMeaning: return Symbols.dictionary; case MessageMode.noneSelected: return Icons.error; - case MessageMode.messageMeaning: - return Icons.star; case MessageMode.wordEmoji: return Symbols.imagesmode; case MessageMode.wordMorph: @@ -53,44 +28,12 @@ extension MessageModeExtension on MessageMode { } } - String title(BuildContext context) { - switch (this) { - case MessageMode.listening: - return L10n.of(context).messageAudio; - case MessageMode.messageSpeechToText: - return L10n.of(context).speechToTextTooltip; - case MessageMode.practiceActivity: - return L10n.of(context).practice; - case MessageMode.wordZoom: - return L10n.of(context).vocab; - case MessageMode.noneSelected: - return ''; - case MessageMode.messageMeaning: - return L10n.of(context).meaning; - case MessageMode.wordEmoji: - return L10n.of(context).image; - case MessageMode.wordMorph: - return L10n.of(context).grammar; - case MessageMode.wordMeaning: - return L10n.of(context).meaning; - } - } - String tooltip(BuildContext context) { switch (this) { case MessageMode.listening: return L10n.of(context).listen; - case MessageMode.messageSpeechToText: - return L10n.of(context).speechToTextTooltip; - case MessageMode.practiceActivity: - return L10n.of(context).practice; - case MessageMode.wordZoom: - return L10n.of(context).vocab; case MessageMode.noneSelected: return ''; - case MessageMode.messageMeaning: - return L10n.of(context).meaning; - //TODO: add L10n case MessageMode.wordEmoji: return L10n.of(context).image; case MessageMode.wordMorph: @@ -100,106 +43,11 @@ extension MessageModeExtension on MessageMode { } } - InstructionsEnum? get instructionsEnum { - switch (this) { - case MessageMode.wordMorph: - return InstructionsEnum.chooseMorphs; - case MessageMode.messageSpeechToText: - return InstructionsEnum.speechToText; - case MessageMode.wordMeaning: - return InstructionsEnum.chooseLemmaMeaning; - case MessageMode.listening: - return InstructionsEnum.chooseWordAudio; - case MessageMode.wordEmoji: - return InstructionsEnum.chooseEmoji; - case MessageMode.noneSelected: - return InstructionsEnum.readingAssistanceOverview; - case MessageMode.messageMeaning: - case MessageMode.wordZoom: - case MessageMode.practiceActivity: - return null; - } - } - - double get pointOnBar { - switch (this) { - // case MessageMode.stats: - // return 1; - case MessageMode.noneSelected: - return 1; - case MessageMode.wordMorph: - return 0.7; - case MessageMode.wordMeaning: - return 0.5; - case MessageMode.listening: - return 0.3; - case MessageMode.messageSpeechToText: - case MessageMode.wordZoom: - case MessageMode.wordEmoji: - case MessageMode.messageMeaning: - case MessageMode.practiceActivity: - return 0; - } - } - - bool isUnlocked( - MessageOverlayController overlayController, - ) { - switch (this) { - case MessageMode.practiceActivity: - case MessageMode.listening: - case MessageMode.messageSpeechToText: - case MessageMode.messageMeaning: - case MessageMode.wordZoom: - case MessageMode.wordEmoji: - case MessageMode.wordMorph: - case MessageMode.wordMeaning: - case MessageMode.noneSelected: - return true; - } - } - - bool get showButton => this != MessageMode.practiceActivity; - - bool isModeDone(MessageOverlayController overlayController) { - switch (this) { - case MessageMode.listening: - return overlayController.isListeningDone; - case MessageMode.wordEmoji: - return overlayController.isEmojiDone; - case MessageMode.wordMorph: - return overlayController.isMorphDone; - case MessageMode.wordMeaning: - return overlayController.isMeaningDone; - default: - return false; - } - } - Color iconButtonColor( BuildContext context, - MessageOverlayController overlayController, - ) { - if (overlayController.isTotallyDone) { - return AppConfig.gold; - } - - //locked - if (!isUnlocked(overlayController)) { - return barAndLockedButtonColor(context); - } - - //unlocked - return isModeDone(overlayController) - ? AppConfig.gold - : Theme.of(context).colorScheme.primaryContainer; - } - - static Color barAndLockedButtonColor(BuildContext context) { - return Theme.of(context).brightness == Brightness.dark - ? Colors.grey[800]! - : Colors.grey[200]!; - } + bool done, + ) => + done ? AppConfig.gold : Theme.of(context).colorScheme.primaryContainer; ActivityTypeEnum? get associatedActivityType { switch (this) { @@ -207,163 +55,19 @@ extension MessageModeExtension on MessageMode { return ActivityTypeEnum.wordMeaning; case MessageMode.listening: return ActivityTypeEnum.wordFocusListening; - case MessageMode.wordEmoji: return ActivityTypeEnum.emoji; - case MessageMode.wordMorph: return ActivityTypeEnum.morphId; - case MessageMode.noneSelected: - case MessageMode.messageMeaning: - case MessageMode.wordZoom: - case MessageMode.messageSpeechToText: - case MessageMode.practiceActivity: return null; } } - /// returns a nullable string of the current level of the message - /// if string is null, then user has completed all levels - /// should be resolvable into a part of speech or morph feature using fromString - /// of the respective enum, PartOfSpeechEnum or MorphFeatureEnum - String? currentChoiceMode( - MessageOverlayController overlayController, - PangeaMessageEvent pangeaMessage, - ) { - switch (this) { - case MessageMode.wordMeaning: - case MessageMode.listening: - case MessageMode.wordEmoji: - // get the pos with some tokens left to practice, from most to least important for learning - return pangeaMessage.messageDisplayRepresentation! - .posSetToPractice(associatedActivityType!) - .firstWhereOrNull( - (pos) => pangeaMessage.messageDisplayRepresentation!.tokens!.any( - (t) => t.vocabConstructID.isActivityProbablyLevelAppropriate( - associatedActivityType!, - t.text.content, - ), - ), - ) - ?.name; - - case MessageMode.wordMorph: - // get the morph feature with some tokens left to practice, from most to least important for learning - return pangeaMessage - .messageDisplayRepresentation!.morphFeatureSetToPractice - .firstWhereOrNull( - (feature) => - pangeaMessage.messageDisplayRepresentation!.tokens!.any((t) { - final String? morphTag = t.getMorphTag(feature); - - if (morphTag == null) { - return false; - } - - return ConstructIdentifier( - lemma: morphTag, - type: ConstructTypeEnum.morph, - category: feature.name, - ).isActivityProbablyLevelAppropriate( - associatedActivityType!, - t.text.content, - ); - }), - ) - ?.name; - - case MessageMode.noneSelected: - case MessageMode.messageMeaning: - case MessageMode.wordZoom: - case MessageMode.messageSpeechToText: - case MessageMode.practiceActivity: - return null; - } - - // final feature = MorphFeaturesEnumExtension.fromString(overlayController); - - // if (feature != null) { - // for (int i; i < pangeaMessage.messageDisplayRepresentation!.morphFeatureSetToPractice.length; i++) { - // if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) { - // return i; - // } - // } - - // for (final feature in pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature)) ?? []) { - // if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) { - // return feature.index; - // } - // } - // } - } - - // List messageModeChoiceLevel( - // MessageOverlayController overlayController, - // PangeaMessageEvent pangeaMessage, - // ) { - // switch (this) { - // case MessageMode.wordMorph: - // final morphFeatureSet = pangeaMessage - // .messageDisplayRepresentation?.morphFeatureSetToPractice; - - // if (morphFeatureSet == null) { - // debugger(when: kDebugMode); - // return []; - // } - - // // sort by the list of priority of parts of speech, defined by their order in the enum - // morphFeatureSet.toList().sort((a, b) => a.index.compareTo(b.index)); - - // debugPrint( - // "morphFeatureSet: ${morphFeatureSet.map((e) => e.name).toList()}", - // ); - // return morphFeatureSet - // .map( - // (feature) => MessageModeChoiceLevelWidget( - // overlayController: overlayController, - // pangeaMessageEvent: pangeaMessage, - // morphFeature: feature, - // ), - // ) - // .toList(); - // case MessageMode.noneSelected: - // case MessageMode.messageMeaning: - // case MessageMode.messageTranslation: - // case MessageMode.messageTextToSpeech: - // case MessageMode.messageSpeechToText: - // case MessageMode.practiceActivity: - // case MessageMode.wordZoom: - // case MessageMode.wordMeaning: - // case MessageMode.wordEmoji: - // if (associatedActivityType == null) { - // debugger(when: kDebugMode); - // return []; - // } - // final posSet = pangeaMessage.messageDisplayRepresentation - // ?.posSetToPractice(associatedActivityType!); - - // if (posSet == null) { - // debugger(when: kDebugMode); - // return []; - // } - - // // sort by the list of priority of parts of speech, defined by their order in the enum - // posSet.toList().sort((a, b) => a.index.compareTo(b.index)); - - // debugPrint("posSet: ${posSet.map((e) => e.name).toList()}"); - - // final widgets = posSet - // .map( - // (pos) => MessageModeChoiceLevelWidget( - // partOfSpeech: pos, - // overlayController: overlayController, - // pangeaMessageEvent: pangeaMessage, - // ), - // ) - // .toList(); - - // return widgets; - // } - // } + static List get practiceModes => [ + MessageMode.listening, + MessageMode.wordMorph, + MessageMode.wordMeaning, + MessageMode.wordEmoji, + ]; } diff --git a/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart b/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart deleted file mode 100644 index f962e1c1f..000000000 --- a/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; - -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart'; -import '../../events/constants/pangea_event_types.dart'; - -class PracticeActivityEvent { - Event event; - Timeline? timeline; - PracticeActivityModel? _content; - - PracticeActivityEvent({ - required this.event, - required this.timeline, - content, - }) { - if (content != null) { - if (!kDebugMode) { - throw Exception( - "content should not be set on product, just a dev placeholder", - ); - } else { - _content = content; - } - } - if (event.type != PangeaEventTypes.pangeaActivity) { - throw Exception( - "${event.type} should not be used to make a PracticeActivityEvent", - ); - } - } - - PracticeActivityModel get practiceActivity { - _content ??= event.getPangeaContent(); - return _content!; - } - - /// All completion records assosiated with this activity - List get allRecords { - if (timeline == null) { - debugger(when: kDebugMode); - return []; - } - final List records = event - .aggregatedEvents(timeline!, PangeaEventTypes.activityRecord) - .toList(); - - return records - .map((event) => PracticeActivityRecordEvent(event: event)) - .toList(); - } - - /// Completion record assosiated with this activity - /// for the logged in user, null if there is none - // List get allUserRecords => allRecords - // .where( - // (recordEvent) => - // recordEvent.event.senderId == recordEvent.event.room.client.userID, - // ) - // .toList(); - - /// Get the most recent user record for this activity - // PracticeActivityRecordEvent? get latestUserRecord { - // final List userRecords = allUserRecords; - // if (userRecords.isEmpty) return null; - // return userRecords.reduce( - // (a, b) => a.event.originServerTs.isAfter(b.event.originServerTs) ? a : b, - // ); - // } - - // DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs; - - String get parentMessageId => event.relationshipEventId!; -} diff --git a/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart b/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart deleted file mode 100644 index b165f7932..000000000 --- a/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_record.dart'; -import '../../events/constants/pangea_event_types.dart'; - -class PracticeActivityRecordEvent { - Event event; - - PracticeRecord? _content; - - PracticeActivityRecordEvent({required this.event}) { - if (event.type != PangeaEventTypes.activityRecord) { - throw Exception( - "${event.type} should not be used to make a PracticeActivityRecordEvent", - ); - } - } - - PracticeRecord get record { - _content ??= event.getPangeaContent(); - return _content!; - } -} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart index c69694c75..263a56371 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart @@ -14,7 +14,7 @@ import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; // this widget will handle the content of the input bar when mode == MessageMode.wordMorph @@ -29,13 +29,17 @@ import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart const int numberOfMorphDistractors = 3; class MessageMorphInputBarContent extends StatefulWidget { - final MessageOverlayController overlayController; + final PracticeController controller; final PracticeActivityModel activity; + final PangeaToken? selectedToken; + final double maxWidth; const MessageMorphInputBarContent({ super.key, - required this.overlayController, + required this.controller, required this.activity, + required this.selectedToken, + required this.maxWidth, }); @override @@ -47,25 +51,19 @@ class MessageMorphInputBarContentState extends State { String? selectedTag; - MessageOverlayController get overlay => widget.overlayController; PangeaToken get token => widget.activity.targetTokens.first; MorphFeaturesEnum get morph => widget.activity.morphFeature!; - @override - void initState() { - super.initState(); - } - @override void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) { - if (morph != oldWidget.overlayController.selectedMorph?.morph || - token != oldWidget.overlayController.selectedToken) { + final selected = widget.controller.selectedMorph?.morph; + if (morph != selected || token != oldWidget.selectedToken) { setState(() {}); } super.didUpdateWidget(oldWidget); } - TextStyle? textStyle(BuildContext context) => overlay.maxWidth > 600 + TextStyle? textStyle(BuildContext context) => widget.maxWidth > 600 ? Theme.of(context).textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.bold, ) @@ -75,14 +73,14 @@ class MessageMorphInputBarContentState @override Widget build(BuildContext context) { - final iconSize = overlay.maxWidth > 600 + final iconSize = widget.maxWidth > 600 ? 28.0 - : overlay.maxWidth > 600 + : widget.maxWidth > 600 ? 24.0 : 16.0; - final spacing = overlay.maxWidth > 600 + final spacing = widget.maxWidth > 600 ? 16.0 - : overlay.maxWidth > 600 + : widget.maxWidth > 600 ? 8.0 : 4.0; @@ -132,7 +130,7 @@ class MessageMorphInputBarContentState ), onTap: () { setState(() => selectedTag = choice); - widget.overlayController.onMatch( + widget.controller.onMatch( token, PracticeChoice( choiceContent: choice, @@ -156,13 +154,13 @@ class MessageMorphInputBarContentState if (selectedTag != null) Container( constraints: BoxConstraints( - minHeight: overlay.maxWidth > 600 ? 20 : 34, + minHeight: widget.maxWidth > 600 ? 20 : 34, ), alignment: Alignment.center, child: MorphMeaningWidget( feature: morph, tag: selectedTag!, - style: overlay.maxWidth > 600 + style: widget.maxWidth > 600 ? Theme.of(context).textTheme.bodyLarge : Theme.of(context).textTheme.bodySmall, ), diff --git a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart index e461e6dad..3d170dd95 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart @@ -13,16 +13,16 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; class MatchActivityCard extends StatelessWidget { final PracticeActivityModel currentActivity; - final MessageOverlayController overlayController; + final PracticeController controller; const MatchActivityCard({ super.key, required this.currentActivity, - required this.overlayController, + required this.controller, }); PracticeActivityModel get activity => currentActivity; @@ -59,8 +59,8 @@ class MatchActivityCard extends StatelessWidget { : Theme.of(context).textTheme.titleMedium?.fontSize) ?? 26; - if (overlayController.toolbarMode == MessageMode.listening || - overlayController.toolbarMode == MessageMode.wordEmoji) { + final mode = controller.practiceMode; + if (mode == MessageMode.listening || mode == MessageMode.wordEmoji) { fontSize = fontSize * 1.5; } @@ -69,10 +69,8 @@ class MatchActivityCard extends StatelessWidget { mainAxisSize: MainAxisSize.max, spacing: 4.0, children: [ - if (overlayController.toolbarMode == MessageMode.listening) - MessageAudioCard( - overlayController: overlayController, - ), + if (mode == MessageMode.listening) + MessageAudioCard(messageEvent: controller.pangeaMessageEvent), Wrap( alignment: WrapAlignment.center, spacing: 4.0, @@ -82,13 +80,13 @@ class MatchActivityCard extends StatelessWidget { final bool? wasCorrect = currentActivity.practiceTarget.wasCorrectMatch(cf); return ChoiceAnimationWidget( - isSelected: overlayController.selectedChoice == cf, + isSelected: controller.selectedChoice == cf, isCorrect: wasCorrect, child: PracticeMatchItem( token: currentActivity.practiceTarget.tokens.firstWhereOrNull( (t) => t.vocabConstructID == cf.form.cId, ), - isSelected: overlayController.selectedChoice == cf, + isSelected: controller.selectedChoice == cf, isCorrect: wasCorrect, constructForm: cf, content: choiceDisplayContent(cf.choiceContent, fontSize), @@ -96,7 +94,7 @@ class MatchActivityCard extends StatelessWidget { activityType == ActivityTypeEnum.wordFocusListening ? cf.choiceContent : null, - overlayController: overlayController, + controller: controller, ), ); }, diff --git a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart index 35c0877a7..5ff61829e 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart @@ -8,10 +8,18 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; class PracticeMatchItem extends StatefulWidget { + final Widget content; + final PangeaToken? token; + final PracticeChoice constructForm; + final String? audioContent; + final PracticeController controller; + final bool? isCorrect; + final bool isSelected; + const PracticeMatchItem({ super.key, required this.content, @@ -20,17 +28,9 @@ class PracticeMatchItem extends StatefulWidget { required this.isCorrect, required this.isSelected, this.audioContent, - required this.overlayController, + required this.controller, }); - final Widget content; - final PangeaToken? token; - final PracticeChoice constructForm; - final String? audioContent; - final MessageOverlayController overlayController; - final bool? isCorrect; - final bool isSelected; - @override PracticeMatchItemState createState() => PracticeMatchItemState(); } @@ -110,9 +110,9 @@ class PracticeMatchItemState extends State { void onTap() { play(); - isCorrect == null || !isCorrect! || widget.token == null - ? widget.overlayController.onChoiceSelect(widget.constructForm) - : widget.overlayController.updateSelectedSpan(widget.token!.text); + if (isCorrect == null || !isCorrect! || widget.token == null) { + widget.controller.onChoiceSelect(widget.constructForm); + } } @override diff --git a/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart b/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart index 1ae1cd716..8e89872ec 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart @@ -2,19 +2,25 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_mode_buttons.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart'; import 'package:fluffychat/widgets/matrix.dart'; const double minContentHeight = 120; class ReadingAssistanceInputBar extends StatefulWidget { - final MessageOverlayController overlayController; + final PracticeController controller; + final PangeaToken? selectedToken; + final double maxWidth; const ReadingAssistanceInputBar( - this.overlayController, { + this.controller, { + required this.maxWidth, + required this.selectedToken, super.key, }); @@ -25,7 +31,6 @@ class ReadingAssistanceInputBar extends StatefulWidget { class ReadingAssistanceInputBarState extends State { final ScrollController _scrollController = ScrollController(); - MessageOverlayController get overlayController => widget.overlayController; @override void dispose() { @@ -33,129 +38,166 @@ class ReadingAssistanceInputBarState extends State { super.dispose(); } - Widget barContent(BuildContext context) { - Widget? content; - final target = overlayController.toolbarMode.associatedActivityType != null - ? overlayController.practiceSelection?.getSelection( - overlayController.toolbarMode.associatedActivityType!, - overlayController.selectedMorph?.token, - overlayController.selectedMorph?.morph, - ) - : null; - - if (overlayController.pangeaMessageEvent.isAudioMessage == true) { - return const SizedBox(); - // return ReactionsPicker(controller); - } else { - final activityType = overlayController.toolbarMode.associatedActivityType; - final activityCompleted = activityType != null && - overlayController.isPracticeActivityDone(activityType); - - switch (overlayController.toolbarMode) { - case MessageMode.messageSpeechToText: - case MessageMode.practiceActivity: - case MessageMode.wordZoom: - case MessageMode.noneSelected: - case MessageMode.messageMeaning: - content = overlayController.isTotallyDone - ? const AllDoneWidget() - : Text( - L10n.of(context).choosePracticeMode, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(fontStyle: FontStyle.italic), - textAlign: TextAlign.center, - ); - - case MessageMode.wordEmoji: - case MessageMode.wordMeaning: - case MessageMode.listening: - if (overlayController.isTotallyDone) { - content = const AllDoneWidget(); - } else if (target == null || activityCompleted) { - content = Text( - L10n.of(context).practiceActivityCompleted, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ); - } else { - content = PracticeActivityCard( - targetTokensAndActivityType: target, - overlayController: overlayController, - ); - } - case MessageMode.wordMorph: - if (overlayController.isTotallyDone) { - content = const AllDoneWidget(); - } else if (activityCompleted) { - content = Text( - L10n.of(context).practiceActivityCompleted, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ); - } else if (target != null) { - content = PracticeActivityCard( - targetTokensAndActivityType: target, - overlayController: overlayController, - ); - } else { - content = Center( - child: Text( - L10n.of(context).selectForGrammar, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ); - } - } - } - - return content; - } - @override Widget build(BuildContext context) { - return Column( - children: [ - PracticeModeButtons( - overlayController: overlayController, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Material( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: Container( - padding: const EdgeInsets.all(8.0), - alignment: Alignment.center, - constraints: const BoxConstraints( - minHeight: minContentHeight, - maxHeight: AppConfig.readingAssistanceInputBarHeight, - ), - child: Scrollbar( - thumbVisibility: true, - controller: _scrollController, - child: SingleChildScrollView( - controller: _scrollController, - child: SizedBox( - width: overlayController.maxWidth, - child: barContent(context), + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + return Column( + spacing: 4.0, + children: [ + Row( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + children: [ + ...MessageMode.practiceModes.map( + (m) => ToolbarButton( + mode: m, + setMode: () => widget.controller.updateToolbarMode(m), + isComplete: widget.controller.isPracticeActivityDone( + m.associatedActivityType!, + ), + isSelected: widget.controller.practiceMode == m, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + child: Container( + padding: const EdgeInsets.all(8.0), + alignment: Alignment.center, + constraints: const BoxConstraints( + minHeight: minContentHeight, + maxHeight: AppConfig.readingAssistanceInputBarHeight, + ), + child: Scrollbar( + thumbVisibility: true, + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: SizedBox( + width: widget.maxWidth, + child: _ReadingAssistanceBarContent( + controller: widget.controller, + selectedToken: widget.selectedToken, + maxWidth: widget.maxWidth, + ), + ), + ), ), ), ), ), - ), - ), - ], + ], + ); + }, ); } } -class AllDoneWidget extends StatelessWidget { - const AllDoneWidget({ - super.key, +class _ReadingAssistanceBarContent extends StatelessWidget { + final PracticeController controller; + final PangeaToken? selectedToken; + final double maxWidth; + + const _ReadingAssistanceBarContent({ + required this.controller, + required this.selectedToken, + required this.maxWidth, }); + @override + Widget build(BuildContext context) { + final mode = controller.practiceMode; + if (controller.pangeaMessageEvent.isAudioMessage == true) { + return const SizedBox(); + } + final activityType = mode.associatedActivityType; + final activityCompleted = + activityType != null && controller.isPracticeActivityDone(activityType); + + switch (mode) { + case MessageMode.noneSelected: + return controller.isTotallyDone + ? const _AllDoneWidget() + : Text( + L10n.of(context).choosePracticeMode, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ); + + case MessageMode.wordEmoji: + case MessageMode.wordMeaning: + case MessageMode.listening: + if (controller.isTotallyDone) { + return const _AllDoneWidget(); + } + + final target = controller.practiceSelection?.getTarget(activityType!); + if (target == null || activityCompleted) { + return Text( + L10n.of(context).practiceActivityCompleted, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ); + } + + return PracticeActivityCard( + targetTokensAndActivityType: target, + controller: controller, + selectedToken: selectedToken, + maxWidth: maxWidth, + ); + case MessageMode.wordMorph: + if (controller.isTotallyDone) { + return const _AllDoneWidget(); + } + if (activityCompleted) { + return Text( + L10n.of(context).practiceActivityCompleted, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ); + } + + PracticeTarget? target; + if (controller.practiceSelection != null && + controller.selectedMorph != null) { + target = controller.practiceSelection!.getMorphTarget( + controller.selectedMorph!.token, + controller.selectedMorph!.morph, + ); + } + + if (target == null) { + return Center( + child: Text( + L10n.of(context).selectForGrammar, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ); + } + + return PracticeActivityCard( + targetTokensAndActivityType: target, + controller: controller, + selectedToken: selectedToken, + maxWidth: maxWidth, + ); + } + } +} + +class _AllDoneWidget extends StatelessWidget { + const _AllDoneWidget(); + @override Widget build(BuildContext context) { return Column( diff --git a/lib/pangea/toolbar/widgets/message_audio_card.dart b/lib/pangea/toolbar/widgets/message_audio_card.dart index da904cbab..dcd48c4bf 100644 --- a/lib/pangea/toolbar/widgets/message_audio_card.dart +++ b/lib/pangea/toolbar/widgets/message_audio_card.dart @@ -12,15 +12,14 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; class MessageAudioCard extends StatefulWidget { - final MessageOverlayController overlayController; + final PangeaMessageEvent messageEvent; final VoidCallback? onError; const MessageAudioCard({ super.key, - required this.overlayController, + required this.messageEvent, this.onError, }); @@ -38,24 +37,21 @@ class MessageAudioCardState extends State { fetchAudio(); } - PangeaMessageEvent get messageEvent => - widget.overlayController.pangeaMessageEvent; - Future fetchAudio() async { if (!mounted) return; setState(() => _isLoading = true); try { - final String langCode = messageEvent.messageDisplayLangCode; - final Event? localEvent = messageEvent.getTextToSpeechLocal( + final String langCode = widget.messageEvent.messageDisplayLangCode; + final Event? localEvent = widget.messageEvent.getTextToSpeechLocal( langCode, - messageEvent.messageDisplayText, + widget.messageEvent.messageDisplayText, ); if (localEvent != null) { audioFile = await localEvent.getPangeaAudioFile(); } else { - audioFile = await messageEvent.getMatrixAudioFile( + audioFile = await widget.messageEvent.getMatrixAudioFile( langCode, ); } @@ -70,7 +66,7 @@ class MessageAudioCardState extends State { m: 'something wrong getting audio in MessageAudioCardState', data: { 'widget.messageEvent.messageDisplayLangCode': - messageEvent.messageDisplayLangCode, + widget.messageEvent.messageDisplayLangCode, }, ); if (mounted) setState(() => _isLoading = false); @@ -84,14 +80,12 @@ class MessageAudioCardState extends State { : audioFile != null ? AudioPlayerWidget( null, - eventId: "${messageEvent.eventId}_practice", - roomId: messageEvent.room.id, - senderId: messageEvent.senderId, + eventId: "${widget.messageEvent.eventId}_practice", + roomId: widget.messageEvent.room.id, + senderId: widget.messageEvent.senderId, matrixFile: audioFile, color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, - chatController: widget.overlayController.widget.chatController, - overlayController: widget.overlayController, linkColor: Theme.of(context).brightness == Brightness.light ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onPrimary, diff --git a/lib/pangea/toolbar/widgets/message_meaning_card.dart b/lib/pangea/toolbar/widgets/message_meaning_card.dart deleted file mode 100644 index 10e954a01..000000000 --- a/lib/pangea/toolbar/widgets/message_meaning_card.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; - -class MessageMeaningCard extends StatelessWidget { - final MessageOverlayController controller; - - const MessageMeaningCard({super.key, required this.controller}); - - @override - Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppConfig.toolbarMinWidth, - maxHeight: AppConfig.toolbarMaxHeight, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.sports_martial_arts, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Flexible( - child: TextButton( - onPressed: () => controller.onRequestForMeaningChallenge(), - child: Text( - L10n.of(context).clickForMeaningActivity, - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index dfb548aee..45e990ec2 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -22,18 +22,9 @@ 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_emoji_picker.dart'; import 'package:fluffychat/pangea/message_token_text/tokens_util.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_choice.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; -import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart'; import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart'; @@ -70,27 +61,10 @@ class MessageSelectionOverlay extends StatefulWidget { class MessageOverlayController extends State with SingleTickerProviderStateMixin { Event get event => widget._event; - ///////////////////////////////////// - /// Variables - ///////////////////////////////////// - MessageMode toolbarMode = MessageMode.noneSelected; - - /// set and cleared by the PracticeActivityCard - /// has to be at this level so drag targets can access it - PracticeActivityModel? activity; - - /// selectedMorph is used for morph activities - MorphSelection? selectedMorph; - - /// tracks selected choice - PracticeChoice? selectedChoice; PangeaTokenText? _selectedSpan; List? _highlightedTokens; - bool initialized = false; - - ReadingAssistanceMode? readingAssistanceMode; // default mode double maxWidth = AppConfig.toolbarMinWidth; @@ -98,6 +72,8 @@ class MessageOverlayController extends State ValueNotifier get selectedMode => selectModeController.selectedMode; + late PracticeController practiceController; + ///////////////////////////////////// /// Lifecycle ///////////////////////////////////// @@ -106,7 +82,8 @@ class MessageOverlayController extends State void initState() { super.initState(); selectModeController = SelectModeController(pangeaMessageEvent); - initializeTokensAndMode(); + practiceController = PracticeController(pangeaMessageEvent); + _initializeTokensAndMode(); WidgetsBinding.instance.addPostFrameCallback( (_) => widget.chatController.setSelectedEvent(event), ); @@ -118,10 +95,11 @@ class MessageOverlayController extends State (_) => widget.chatController.clearSelectedEvents(), ); selectModeController.dispose(); + practiceController.dispose(); super.dispose(); } - Future initializeTokensAndMode() async { + Future _initializeTokensAndMode() async { try { if (pangeaMessageEvent.event.messageType != MessageTypes.Text) { return; @@ -154,51 +132,19 @@ class MessageOverlayController extends State ); } finally { _initializeSelectedToken(); - _setInitialToolbarMode(); - initialized = true; if (mounted) setState(() {}); } } - Future _setInitialToolbarMode() async { - // 1) if we have a hidden word activity, then we should start with that - if (practiceSelection?.hasHiddenWordActivity ?? false) { - updateToolbarMode(MessageMode.practiceActivity); - return; - } - } - /// Decides whether an _initialSelectedToken should be used /// for a first practice activity on the word meaning Future _initializeSelectedToken() async { // if there is no initial selected token, then we don't need to do anything - if (widget._initialSelectedToken == null || practiceSelection == null) { - return; - } - - // should not already be involved in a hidden word activity - // final isInHiddenWordActivity = - // messageAnalyticsEntry!.isTokenInHiddenWordActivity( - // widget._initialSelectedToken!, - // ); - - // // whether the activity should generally be involved in an activity - if (practiceSelection?.hasHiddenWordActivity == true) { + if (widget._initialSelectedToken == null) { return; } updateSelectedSpan(widget._initialSelectedToken!.text); - - // int retries = 0; - // while (retries < 5 && - // selectedToken != null && - // !MatrixState.pAnyState.isOverlayOpen( - // selectedToken!.text.uniqueKey, - // )) { - // await Future.delayed(const Duration(milliseconds: 100)); - // _showReadingAssistanceContent(); - // retries++; - // } } ///////////////////////////////////// @@ -255,9 +201,9 @@ class MessageOverlayController extends State } if (selectedSpan == _selectedSpan) return; - if (selectedMorph != null) { - selectedMorph = null; - } + // if (selectedMorph != null) { + // selectedMorph = null; + // } _selectedSpan = selectedSpan; if (selectedMode.value == SelectMode.emoji && selectedToken != null) { @@ -265,125 +211,16 @@ class MessageOverlayController extends State } if (mounted) { setState(() {}); - if (selectedToken != null) onSelectNewToken(selectedToken!); + if (selectedToken != null) _onSelectNewToken(selectedToken!); } } - void updateToolbarMode(MessageMode mode) => setState(() { - selectedChoice = null; - - // close overlay of any selected token - if (_selectedSpan != null) { - updateSelectedSpan(_selectedSpan!); - } - - toolbarMode = mode; - if (toolbarMode != MessageMode.wordMorph) { - selectedMorph = null; - } - }); - - /////////////////////////////////// - /// User action handlers - ///////////////////////////////////// - void onRequestForMeaningChallenge() { - if (practiceSelection == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: "MessageAnalyticsEntry is null in onRequestForMeaningChallenge", - data: {}, - ); - return; - } - practiceSelection!.addMessageMeaningActivity(); - - if (mounted) { - setState(() {}); - } - } - - void onChoiceSelect(PracticeChoice? choice, [bool force = false]) { - if (selectedChoice == choice && !force) { - selectedChoice = null; - } else { - selectedChoice = choice; - } - - setState(() {}); - } - - void onMorphActivitySelect(MorphSelection newMorph) { - toolbarMode = MessageMode.wordMorph; - // // close overlay of previous token - if (_selectedSpan != null && _selectedSpan != newMorph.token.text) { - updateSelectedSpan(_selectedSpan!); - } - selectedMorph = newMorph; - setState(() {}); - } - - void onMatch(PangeaToken token, PracticeChoice choice) { - if (activity == null) return; - activity!.activityType == ActivityTypeEnum.morphId - ? activity!.onMultipleChoiceSelect( - token, - choice, - pangeaMessageEvent, - () => setState(() {}), - ) - : activity!.onMatch( - token, - choice, - pangeaMessageEvent, - () => setState(() {}), - ); - - if (isTotallyDone) { - OverlayUtil.showStarRainOverlay(context); - } - } - - ///////////////////////////////////// - /// Getters - //////////////////////////////////// PangeaMessageEvent get pangeaMessageEvent => PangeaMessageEvent( event: widget._event, timeline: widget._timeline, ownMessage: widget._event.room.client.userID == widget._event.senderId, ); - bool get hideWordCardContent => - readingAssistanceMode == ReadingAssistanceMode.practiceMode; - - bool isPracticeActivityDone(ActivityTypeEnum activityType) => - practiceSelection?.activities(activityType).every((a) => a.isComplete) == - true; - - bool get isEmojiDone => isPracticeActivityDone(ActivityTypeEnum.emoji); - - bool get isMeaningDone => - isPracticeActivityDone(ActivityTypeEnum.wordMeaning); - - bool get isListeningDone => - isPracticeActivityDone(ActivityTypeEnum.wordFocusListening); - - bool get isMorphDone => isPracticeActivityDone(ActivityTypeEnum.morphId); - - bool get isTotallyDone => - isEmojiDone && isMeaningDone && isListeningDone && isMorphDone; - - PracticeSelection? get practiceSelection => - pangeaMessageEvent.messageDisplayRepresentation?.tokens != null - ? PracticeSelectionRepo.get( - pangeaMessageEvent.messageDisplayLangCode, - pangeaMessageEvent.messageDisplayRepresentation!.tokens!, - ) - : null; - - bool get messageInUserL2 => - pangeaMessageEvent.messageDisplayLangCode.split("-")[0] == - MatrixState.pangeaController.languageController.userL2?.langCodeShort; - PangeaToken? get selectedToken { if (pangeaMessageEvent.isAudioMessage == true) { final stt = pangeaMessageEvent.getSpeechToTextLocal(); @@ -410,10 +247,6 @@ class MessageOverlayController extends State return event.messageType == MessageTypes.Audio; } - /////////////////////////////////// - /// Functions - ///////////////////////////////////// - /// If sentence TTS is playing a word, highlight that word in message overlay void highlightCurrentText(int currentPosition, List ttsTokens) { final List textToSelect = []; @@ -451,57 +284,26 @@ class MessageOverlayController extends State ); } - /// When an activity is completed, we need to update the state - /// and check if the toolbar should be unlocked - void onActivityFinish(ActivityTypeEnum activityType, PangeaToken? token) { - // if (selectedToken == null) { - // updateToolbarMode(MessageMode.noneSelected); - // } - - if (!mounted) return; - setState(() {}); - } - - /// In some cases, we need to exit the practice flow and let the user - /// interact with the toolbar without completing activities - void exitPracticeFlow() { - practiceSelection?.exitPracticeFlow(); - setState(() {}); - } - - PracticeTarget? practiceTargetForToken(PangeaToken token) { - if (toolbarMode.associatedActivityType == null) return null; - return practiceSelection - ?.activities(toolbarMode.associatedActivityType!) - .firstWhereOrNull((a) => a.tokens.contains(token)); - } - void onClickOverlayMessageToken( PangeaToken token, ) { - if (practiceSelection?.hasHiddenWordActivity == true || - readingAssistanceMode == ReadingAssistanceMode.practiceMode) { - return; - } - - /// we don't want to associate the audio with the text in this mode - if (practiceSelection?.hasActiveActivityByToken( - ActivityTypeEnum.wordFocusListening, - token, - ) == - false || - !hideWordCardContent) { - TtsController.tryToSpeak( - token.text.content, - targetID: null, - langCode: pangeaMessageEvent.messageDisplayLangCode, - ); - } - + // /// we don't want to associate the audio with the text in this mode + // if (practiceSelection?.hasActiveActivityByToken( + // ActivityTypeEnum.wordFocusListening, + // token, + // ) == + // false || + // !hideWordCardContent) { + // TtsController.tryToSpeak( + // token.text.content, + // targetID: null, + // langCode: pangeaMessageEvent.messageDisplayLangCode, + // ); + // } updateSelectedSpan(token.text); } - void onSelectNewToken(PangeaToken token) { + void _onSelectNewToken(PangeaToken token) { if (!isNewToken(token)) return; MatrixState.pangeaController.putAnalytics.setState( AnalyticsStream( @@ -569,7 +371,7 @@ class MessageOverlayController extends State onSelect: (emoji) async { final resp = await showFutureLoadingDialog( context: context, - future: () => setTokenEmoji(token, emoji), + future: () => _setTokenEmoji(token, emoji), ); if (mounted && !resp.isError) { MatrixState.pAnyState.closeOverlay( @@ -591,7 +393,7 @@ class MessageOverlayController extends State ); } - Future setTokenEmoji(PangeaToken token, String emoji) async { + Future _setTokenEmoji(PangeaToken token, String emoji) async { await token.setEmoji([emoji]); if (mounted) setState(() {}); } @@ -599,9 +401,6 @@ class MessageOverlayController extends State String tokenEmojiPopupKey(PangeaToken token) => "${token.uniqueId}_${event.eventId}_emoji_button"; - ///////////////////////////////////// - /// Build - ///////////////////////////////////// @override Widget build(BuildContext context) { return MessageSelectionPositioner( diff --git a/lib/pangea/toolbar/widgets/message_selection_positioner.dart b/lib/pangea/toolbar/widgets/message_selection_positioner.dart index a28d8b29f..c1bf6cb26 100644 --- a/lib/pangea/toolbar/widgets/message_selection_positioner.dart +++ b/lib/pangea/toolbar/widgets/message_selection_positioner.dart @@ -55,8 +55,8 @@ class MessageSelectionPositionerState extends State ScrollController? scrollController; - bool finishedTransition = false; - bool _startedTransition = false; + ValueNotifier finishedTransition = ValueNotifier(false); + final ValueNotifier _startedTransition = ValueNotifier(false); ReadingAssistanceMode readingAssistanceMode = ReadingAssistanceMode.selectMode; @@ -355,19 +355,11 @@ class MessageSelectionPositionerState extends State } void onStartedTransition() { - if (mounted) { - setState(() { - _startedTransition = true; - }); - } + if (mounted) _startedTransition.value = true; } void onFinishedTransition() { - if (mounted) { - setState(() { - finishedTransition = true; - }); - } + if (mounted) finishedTransition.value = true; } void launchPractice(ReadingAssistanceMode mode) { @@ -402,21 +394,30 @@ class MessageSelectionPositionerState extends State alignment: ownMessage ? Alignment.centerRight : Alignment.centerLeft, children: [ - if (!_startedTransition) ...[ - OverMessageOverlay(controller: this), - if (shouldScroll) - Positioned( - top: 0, - left: _wordCardLeftOffset, - right: messageRightOffset, - child: WordCardSwitcher(controller: this), - ), - ], + ValueListenableBuilder( + valueListenable: _startedTransition, + builder: (context, started, __) { + return !started + ? OverMessageOverlay(controller: this) + : const SizedBox(); + }, + ), + ValueListenableBuilder( + valueListenable: _startedTransition, + builder: (context, started, __) { + return !started && shouldScroll + ? Positioned( + top: 0, + left: _wordCardLeftOffset, + right: messageRightOffset, + child: WordCardSwitcher(controller: this), + ) + : const SizedBox(); + }, + ), if (readingAssistanceMode == ReadingAssistanceMode.practiceMode) ...[ CenteredMessage( - targetId: - "overlay_center_message_${widget.event.eventId}", controller: this, ), PracticeModeTransitionAnimation( @@ -429,7 +430,9 @@ class MessageSelectionPositionerState extends State right: 0, bottom: 20, child: ReadingAssistanceInputBar( - widget.overlayController, + widget.overlayController.practiceController, + maxWidth: widget.overlayController.maxWidth, + selectedToken: widget.overlayController.selectedToken, ), ), ], diff --git a/lib/pangea/toolbar/widgets/over_message_overlay.dart b/lib/pangea/toolbar/widgets/over_message_overlay.dart index da3b2e652..8e92339f7 100644 --- a/lib/pangea/toolbar/widgets/over_message_overlay.dart +++ b/lib/pangea/toolbar/widgets/over_message_overlay.dart @@ -68,11 +68,8 @@ class OverMessageOverlay extends StatelessWidget { hasReactions: controller.hasReactions, isTransitionAnimation: true, readingAssistanceMode: controller.readingAssistanceMode, - overlayKey: MatrixState.pAnyState - .layerLinkAndKey( - 'overlay_message_${controller.widget.event.eventId}', - ) - .key, + overlayKey: + 'overlay_message_${controller.widget.event.eventId}', ); }, ), diff --git a/lib/pangea/toolbar/widgets/overlay_center_content.dart b/lib/pangea/toolbar/widgets/overlay_center_content.dart index 954c4702f..be5c31c92 100644 --- a/lib/pangea/toolbar/widgets/overlay_center_content.dart +++ b/lib/pangea/toolbar/widgets/overlay_center_content.dart @@ -29,10 +29,11 @@ class OverlayCenterContent extends StatelessWidget { final bool isTransitionAnimation; final ReadingAssistanceMode? readingAssistanceMode; - final LabeledGlobalKey? overlayKey; + final String overlayKey; const OverlayCenterContent({ required this.event, + required this.overlayKey, this.messageHeight, this.messageWidth, required this.overlayController, @@ -44,7 +45,6 @@ class OverlayCenterContent extends StatelessWidget { this.sizeAnimation, this.isTransitionAnimation = false, this.readingAssistanceMode, - this.overlayKey, super.key, }); @@ -69,7 +69,7 @@ class OverlayCenterContent extends StatelessWidget { MeasureRenderBox( onChange: onChangeMessageSize, child: OverlayMessage( - key: overlayKey, + overlayKey: overlayKey, event, controller: chatController, overlayController: overlayController, diff --git a/lib/pangea/toolbar/widgets/overlay_message.dart b/lib/pangea/toolbar/widgets/overlay_message.dart index e0d9182ba..1f530817c 100644 --- a/lib/pangea/toolbar/widgets/overlay_message.dart +++ b/lib/pangea/toolbar/widgets/overlay_message.dart @@ -41,6 +41,7 @@ class OverlayMessage extends StatelessWidget { final bool isTransitionAnimation; final ReadingAssistanceMode? readingAssistanceMode; + final String overlayKey; const OverlayMessage( this.event, { @@ -49,6 +50,7 @@ class OverlayMessage extends StatelessWidget { required this.timeline, required this.messageWidth, required this.messageHeight, + required this.overlayKey, this.nextEvent, this.previousEvent, this.sizeAnimation, @@ -274,6 +276,7 @@ class OverlayMessage extends StatelessWidget { ); return Material( + key: MatrixState.pAnyState.layerLinkAndKey(overlayKey).key, type: MaterialType.transparency, child: Container( clipBehavior: Clip.antiAlias, diff --git a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart deleted file mode 100644 index f5ce6de8f..000000000 --- a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'dart:developer'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/common/widgets/choice_array.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.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_record.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -/// The multiple choice activity view -class MultipleChoiceActivity extends StatefulWidget { - final PracticeActivityCardState practiceCardController; - final PracticeActivityModel currentActivity; - final VoidCallback? onError; - final MessageOverlayController overlayController; - final String? initialSelectedChoice; - final bool clearResponsesOnUpdate; - - const MultipleChoiceActivity({ - super.key, - required this.practiceCardController, - required this.currentActivity, - required this.overlayController, - this.initialSelectedChoice, - this.clearResponsesOnUpdate = false, - this.onError, - }); - - @override - MultipleChoiceActivityState createState() => MultipleChoiceActivityState(); -} - -class MultipleChoiceActivityState extends State { - int? selectedChoiceIndex; - - PracticeRecord? get currentRecordModel => - widget.practiceCardController.currentCompletionRecord; - - @override - void initState() { - super.initState(); - if (widget.currentActivity.multipleChoiceContent == null) { - throw Exception( - "MultipleChoiceActivityState: currentActivity.multipleChoiceContent is null", - ); - } - if (widget.initialSelectedChoice != null) { - currentRecordModel?.addResponse( - target: widget.currentActivity.practiceTarget, - cId: widget.currentActivity.morphFeature == null - ? widget.currentActivity.targetTokens.first.vocabConstructID - : widget.currentActivity.targetTokens.first - .morphIdByFeature(widget.currentActivity.morphFeature!)!, - text: widget.initialSelectedChoice, - score: 1, - ); - } - } - - @override - void didUpdateWidget(covariant MultipleChoiceActivity oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.currentActivity.hashCode != oldWidget.currentActivity.hashCode) { - setState(() => selectedChoiceIndex = null); - } - } - - void updateChoice(String value, int index) { - final bool isCorrect = - widget.currentActivity.multipleChoiceContent!.isCorrect(value, index); - - if (currentRecordModel?.hasTextResponse(value) ?? false) { - return; - } - - if (widget.clearResponsesOnUpdate) { - currentRecordModel?.clearResponses(); - } - - currentRecordModel?.addResponse( - target: widget.currentActivity.practiceTarget, - cId: widget.currentActivity.morphFeature == null - ? widget.currentActivity.targetTokens.first.vocabConstructID - : widget.currentActivity.targetTokens.first - .morphIdByFeature(widget.currentActivity.morphFeature!)!, - text: value, - score: isCorrect ? 1 : 0, - ); - - if (currentRecordModel == null || - currentRecordModel?.latestResponse == null || - widget.practiceCardController.currentActivity == null) { - ErrorHandler.logError( - e: "Missing necessary information to send analytics in multiple choice activity", - data: { - "currentRecordModel": currentRecordModel, - "latestResponse": currentRecordModel?.latestResponse, - "currentActivity": widget.practiceCardController.currentActivity, - }, - ); - debugger(when: kDebugMode); - return; - } - - MatrixState.pangeaController.putAnalytics.setState( - AnalyticsStream( - // note - this maybe should be the activity event id - eventId: widget.overlayController.event.eventId, - roomId: widget.overlayController.event.room.id, - constructs: currentRecordModel!.latestResponse!.toUses( - widget.practiceCardController.currentActivity!, - widget.practiceCardController.metadata, - ), - ), - ); - - // If the selected choice is correct, send the record - if (widget.currentActivity.multipleChoiceContent!.isCorrect(value, index)) { - // If the activity is an emoji activity, set the emoji value - - // TODO: this widget is deprecated for use with emoji activities - // if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) { - // if (widget.currentActivity.targetTokens?.length != 1) { - // debugger(when: kDebugMode); - // } else { - // widget.currentActivity.targetTokens!.first.setEmoji(value); - // } - // } - - // The next entry in the analytics stream should be from the above putAnalytics.setState. - // So we can wait for the stream to update before calling onActivityFinish. - final streamFuture = MatrixState - .pangeaController.getAnalytics.analyticsStream.stream.first; - streamFuture.then((_) { - widget.practiceCardController.onActivityFinish(); - }); - } - - if (mounted) { - setState( - () => selectedChoiceIndex = index, - ); - } - } - - List choices(BuildContext context) { - final activity = widget.currentActivity.multipleChoiceContent; - final List choices = []; - for (int i = 0; i < activity!.choices.length; i++) { - final String value = activity.choices[i]; - final color = currentRecordModel?.hasTextResponse(value) ?? false - ? activity.choiceColor(i) - : null; - final isGold = activity.isCorrect(value, i); - choices.add( - Choice( - text: value, - color: color, - isGold: isGold, - ), - ); - } - return choices; - } - - String _getDisplayCopy(String value) { - if (widget.currentActivity.activityType != ActivityTypeEnum.morphId) { - return value; - } - final morphFeature = widget - .practiceCardController.widget.targetTokensAndActivityType.morphFeature; - if (morphFeature == null) return value; - - return getGrammarCopy( - category: morphFeature.name, - lemma: value, - context: context, - ) ?? - value; - } - - @override - Widget build(BuildContext context) { - final PracticeActivityModel practiceActivity = widget.currentActivity; - final question = practiceActivity.multipleChoiceContent!.question; - - // if (ActivityTypeEnum.emoji == practiceActivity.activityType) { - // return WordEmojiChoiceRow( - // activity: practiceActivity, - // selectedChoiceIndex: selectedChoiceIndex, - // onTap: updateChoice, - // ); - // } - - final content = Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (question.isNotEmpty) - Text( - question, - textAlign: TextAlign.center, - style: AppConfig.messageTextStyle( - widget.overlayController.event, - Theme.of(context).colorScheme.primary, - ).merge(const TextStyle(fontStyle: FontStyle.italic)), - ), - if (question.isNotEmpty) const SizedBox(height: 8.0), - const SizedBox(height: 8), - if (practiceActivity.activityType == - ActivityTypeEnum.wordFocusListening) - WordAudioButton( - text: practiceActivity.multipleChoiceContent!.answers.first, - uniqueID: - "audio-activity-${widget.overlayController.event.eventId}", - langCode: widget - .overlayController.pangeaMessageEvent.messageDisplayLangCode, - ), - if (practiceActivity.activityType == - ActivityTypeEnum.hiddenWordListening) - MessageAudioCard( - overlayController: widget.overlayController, - onError: widget.onError, - ), - ChoicesArray( - isLoading: false, - onPressed: updateChoice, - selectedChoiceIndex: selectedChoiceIndex, - choices: choices(context), - id: currentRecordModel?.hashCode.toString(), - enableAudio: practiceActivity.activityType.includeTTSOnClick, - langCode: - MatrixState.pangeaController.languageController.activeL2Code(), - getDisplayCopy: _getDisplayCopy, - ), - ], - ); - - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppConfig.toolbarMinWidth, - maxHeight: AppConfig.toolbarMaxHeight, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: content, - ), - ); - } -} diff --git a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart index c12bad187..4cba63fb1 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart @@ -1,28 +1,18 @@ import 'dart:async'; -import 'dart:developer'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/utils/async_state.dart'; import 'package:fluffychat/pangea/common/widgets/card_error_widget.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.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/events/models/pangea_token_model.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/practice_activities/practice_record.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// The wrapper for practice activity content. @@ -30,12 +20,16 @@ import 'package:fluffychat/widgets/matrix.dart'; /// their navigation, and the management of completion records class PracticeActivityCard extends StatefulWidget { final PracticeTarget targetTokensAndActivityType; - final MessageOverlayController overlayController; + final PracticeController controller; + final PangeaToken? selectedToken; + final double maxWidth; const PracticeActivityCard({ super.key, required this.targetTokensAndActivityType, - required this.overlayController, + required this.controller, + required this.selectedToken, + required this.maxWidth, }); @override @@ -43,23 +37,8 @@ class PracticeActivityCard extends StatefulWidget { } class PracticeActivityCardState extends State { - bool fetchingActivity = true; - bool savoringTheJoy = false; - - Completer? currentActivityCompleter; - - PracticeRepo practiceGenerationController = PracticeRepo(); - - PangeaController get pangeaController => MatrixState.pangeaController; - String? _error; - - PracticeActivityModel? get currentActivity => - widget.overlayController.activity; - - PracticeRecord? get currentCompletionRecord => currentActivity?.record; - - PangeaMessageEvent? get pangeaMessageEvent => - widget.overlayController.pangeaMessageEvent; + final ValueNotifier> _activityState = + ValueNotifier(const AsyncState.loading()); @override void initState() { @@ -80,237 +59,61 @@ class PracticeActivityCardState extends State { @override void dispose() { - practiceGenerationController.dispose(); + _activityState.dispose(); super.dispose(); } - void _updateFetchingActivity(bool value) { - if (fetchingActivity == value) return; - if (mounted) setState(() => fetchingActivity = value); - } - - Future _fetchActivity({ - ActivityQualityFeedback? activityFeedback, - }) async { - _error = null; - if (!mounted || - !pangeaController.languageController.languagesSet || - widget.overlayController.practiceSelection == null) { - _updateFetchingActivity(false); + Future _fetchActivity() async { + _activityState.value = const AsyncState.loading(); + if (!MatrixState.pangeaController.languageController.languagesSet) { + _activityState.value = const AsyncState.error("Error fetching activity"); return; } - try { - _updateFetchingActivity(true); - final activity = await _fetchActivityModel( - activityFeedback: activityFeedback, - ); - - if (activity == null) { - widget.overlayController.exitPracticeFlow(); - return; - } - - widget.overlayController - .setState(() => widget.overlayController.activity = activity); - } catch (e, s) { - debugPrint( - "Error fetching activity: $e", - ); - ErrorHandler.logError( - e: e, - s: s, - data: { - 'activity': currentActivity?.toJson(), - 'record': currentCompletionRecord?.toJson(), - 'targetTokens': widget.targetTokensAndActivityType.tokens - .map((token) => token.toJson()) - .toList(), - 'activityType': widget.targetTokensAndActivityType.activityType, - 'morphFeature': widget.targetTokensAndActivityType.morphFeature, - }, - ); - debugger(when: kDebugMode); - _error = e.toString(); - } finally { - _updateFetchingActivity(false); - } - } - - Future _fetchActivityModel({ - ActivityQualityFeedback? activityFeedback, - }) async { - debugPrint( - "fetching activity model of type: ${widget.targetTokensAndActivityType.activityType}", - ); - if (pangeaMessageEvent == null) return null; - // check if we already have an activity matching the specs - final tokens = widget.targetTokensAndActivityType.tokens; - final type = widget.targetTokensAndActivityType.activityType; - final morph = widget.targetTokensAndActivityType.morphFeature; - - // final existingActivity = - // widget.pangeaMessageEvent.practiceActivities.firstWhereOrNull( - // (activity) => - // activity.practiceActivity.activityType == type && - // const ListEquality() - // .equals(widget.targetTokensAndActivityType.tokens, tokens) && - // activity.practiceActivity.morphFeature == morph, - // ); - - // if (existingActivity != null) { - // currentActivityCompleter = Completer(); - // currentActivityCompleter!.complete(existingActivity); - // existingActivity.practiceActivity.targetTokens = tokens; - // return existingActivity.practiceActivity; - // } - - final req = MessageActivityRequest( - userL1: MatrixState.pangeaController.languageController.userL1!.langCode, - userL2: MatrixState.pangeaController.languageController.userL2!.langCode, - messageText: pangeaMessageEvent!.messageDisplayText, - messageTokens: - pangeaMessageEvent?.messageDisplayRepresentation?.tokens ?? [], - activityQualityFeedback: activityFeedback, - targetTokens: tokens, - targetType: type, - targetMorphFeature: morph, + final result = await widget.controller.fetchActivityModel( + widget.targetTokensAndActivityType, ); - final PracticeActivityModelResponse activityResponse = - await practiceGenerationController.getPracticeActivity( - req, - pangeaMessageEvent, - context, - ); - - if (activityResponse.activity == null) return null; - - currentActivityCompleter = activityResponse.eventCompleter; - activityResponse.activity!.targetTokens = tokens; - return activityResponse.activity; - } - - ConstructUseMetaData get metadata => ConstructUseMetaData( - eventId: widget.overlayController.event.eventId, - roomId: widget.overlayController.event.room.id, - timeStamp: DateTime.now(), - ); - - final Duration _savorTheJoyDuration = const Duration(seconds: 1); - - Future _savorTheJoy() async { - try { - if (mounted) setState(() => savoringTheJoy = true); - await Future.delayed(_savorTheJoyDuration); - if (mounted) setState(() => savoringTheJoy = false); - } catch (e, s) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: e, - s: s, - m: 'Failed to savor the joy', - data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, - }, + if (result.isValue) { + _activityState.value = AsyncState.loaded(result.result!); + } else { + _activityState.value = AsyncState.error( + "Error fetching activity: ${result.asError}", ); } } - /// Called when the user finishes an activity. - /// Saves the completion record and sends it to the server. - /// Fetches a new activity if there are any left to complete. - /// Exits the practice flow if there are no more activities. - void onActivityFinish() async { - try { - if (currentCompletionRecord == null || currentActivity == null) { - debugger(when: kDebugMode); - return; - } - - await _savorTheJoy(); - - // wait for savor the joy before popping from the activity queue - // to keep the completed activity on screen for a moment - widget.overlayController - .onActivityFinish(currentActivity!.activityType, null); - - TtsController.stop(); - } catch (e, s) { - _onError(); - debugger(when: kDebugMode); - ErrorHandler.logError( - e: e, - s: s, - data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, - }, - ); - } - } - - void _onError() { - // widget.overlayController.practiceSelection?.revealAllTokens(); - // widget.overlayController.activity = null; - // widget.overlayController.exitPracticeFlow(); - } - - // /// The widget that displays the current activity. - // /// If there is no current activity, the widget returns a sizedbox with a height of 80. - // /// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity. - // /// If the activity type is unknown, the widget logs an error and returns a text widget with an error message. - Widget? get activityWidget { - if (currentActivity == null) { - return null; - } - if (currentActivity!.multipleChoiceContent != null) { - if (currentActivity!.activityType == ActivityTypeEnum.morphId) { - return MessageMorphInputBarContent( - overlayController: widget.overlayController, - activity: currentActivity!, - ); - } - return MultipleChoiceActivity( - practiceCardController: this, - currentActivity: currentActivity!, - onError: _onError, - overlayController: widget.overlayController, - initialSelectedChoice: null, - clearResponsesOnUpdate: - currentActivity?.activityType == ActivityTypeEnum.emoji, - ); - } - if (currentActivity!.matchContent != null) { - return MatchActivityCard( - currentActivity: currentActivity!, - overlayController: widget.overlayController, - ); - } - debugger(when: kDebugMode); - return const Text("No activity found"); - } - @override Widget build(BuildContext context) { - if (_error != null || (!fetchingActivity && currentActivity == null)) { - debugger(when: kDebugMode); - return CardErrorWidget(L10n.of(context).errorFetchingActivity); - } - - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (activityWidget != null && !fetchingActivity) activityWidget!, - // Conditionally show the darkening and progress indicator based on the loading state - if (!savoringTheJoy && fetchingActivity) ...[ - // Circular progress indicator in the center - const ToolbarContentLoadingIndicator( - height: 40, - ), - ], - ], + return ValueListenableBuilder( + valueListenable: _activityState, + builder: (context, state, __) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + switch (state) { + AsyncLoading() => const ToolbarContentLoadingIndicator( + height: 40, + ), + AsyncError() => CardErrorWidget( + L10n.of(context).errorFetchingActivity, + ), + AsyncLoaded() => state.value.multipleChoiceContent != null + ? MessageMorphInputBarContent( + controller: widget.controller, + activity: state.value, + selectedToken: widget.selectedToken, + maxWidth: widget.maxWidth, + ) + : MatchActivityCard( + currentActivity: state.value, + controller: widget.controller, + ), + _ => const SizedBox.shrink(), + }, + ], + ); + }, ); } } diff --git a/lib/pangea/toolbar/widgets/practice_controller.dart b/lib/pangea/toolbar/widgets/practice_controller.dart new file mode 100644 index 000000000..83c24880d --- /dev/null +++ b/lib/pangea/toolbar/widgets/practice_controller.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; + +import 'package:async/async.dart'; +import 'package:collection/collection.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/put_analytics_controller.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.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_choice.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_selection.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class PracticeController with ChangeNotifier { + final PangeaMessageEvent pangeaMessageEvent; + + PracticeController(this.pangeaMessageEvent); + + PracticeActivityModel? _activity; + + MessageMode practiceMode = MessageMode.noneSelected; + + MorphSelection? selectedMorph; + PracticeChoice? selectedChoice; + + PracticeActivityModel? get activity => _activity; + + PracticeSelection? get practiceSelection => + pangeaMessageEvent.messageDisplayRepresentation?.tokens != null + ? PracticeSelectionRepo.get( + pangeaMessageEvent.eventId, + pangeaMessageEvent.messageDisplayLangCode, + pangeaMessageEvent.messageDisplayRepresentation!.tokens!, + ) + : null; + + bool get isTotallyDone => + isPracticeActivityDone(ActivityTypeEnum.emoji) && + isPracticeActivityDone(ActivityTypeEnum.wordMeaning) && + isPracticeActivityDone(ActivityTypeEnum.wordFocusListening) && + isPracticeActivityDone(ActivityTypeEnum.morphId); + + bool isPracticeActivityDone(ActivityTypeEnum activityType) => + practiceSelection?.activities(activityType).every((a) => a.isComplete) == + true; + + Future> fetchActivityModel( + PracticeTarget target, + ) async { + final req = MessageActivityRequest( + userL1: MatrixState.pangeaController.languageController.userL1!.langCode, + userL2: MatrixState.pangeaController.languageController.userL2!.langCode, + messageText: pangeaMessageEvent.messageDisplayText, + messageTokens: + pangeaMessageEvent.messageDisplayRepresentation?.tokens ?? [], + activityQualityFeedback: null, + targetTokens: target.tokens, + targetType: target.activityType, + targetMorphFeature: target.morphFeature, + ); + + final result = await PracticeRepo.getPracticeActivity(req); + if (result.isValue) { + _activity = result.result; + } + + return result; + } + + PracticeTarget? practiceTargetForToken(PangeaToken token) { + if (practiceMode.associatedActivityType == null) return null; + return practiceSelection + ?.activities(practiceMode.associatedActivityType!) + .firstWhereOrNull((a) => a.tokens.contains(token)); + } + + void updateToolbarMode(MessageMode mode) { + selectedChoice = null; + practiceMode = mode; + if (practiceMode != MessageMode.wordMorph) { + selectedMorph = null; + } + notifyListeners(); + } + + void onChoiceSelect(PracticeChoice? choice, [bool force = false]) { + if (_activity == null) return; + if (selectedChoice == choice && !force) { + selectedChoice = null; + } else { + selectedChoice = choice; + } + notifyListeners(); + } + + void onSelectMorph(MorphSelection newMorph) { + practiceMode = MessageMode.wordMorph; + selectedMorph = newMorph; + notifyListeners(); + } + + void onMatch(PangeaToken token, PracticeChoice choice) { + if (_activity == null) return; + + final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId + ? _activity!.onMultipleChoiceSelect(token, choice) + : _activity!.onMatch(token, choice); + + // we don't take off points for incorrect emoji matches + if (_activity!.activityType != ActivityTypeEnum.emoji || isCorrect) { + final constructUseType = _activity!.practiceTarget.record.responses.last + .useType(_activity!.activityType); + + MatrixState.pangeaController.putAnalytics.setState( + AnalyticsStream( + eventId: pangeaMessageEvent.eventId, + roomId: pangeaMessageEvent.room.id, + constructs: [ + OneConstructUse( + useType: constructUseType, + lemma: token.lemma.text, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), + eventId: pangeaMessageEvent.eventId, + ), + category: token.pos, + // in the case of a wrong answer, the cId doesn't match the token + form: token.text.content, + xp: constructUseType.pointValue, + ), + ], + targetID: + "message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}", + ), + ); + } + + if (isCorrect) { + if (_activity!.activityType == ActivityTypeEnum.emoji) { + choice.form.cId.setEmojiWithXP( + emoji: choice.choiceContent, + isFromCorrectAnswer: true, + eventId: pangeaMessageEvent.eventId, + roomId: pangeaMessageEvent.room.id, + ); + } + + if (_activity!.activityType == ActivityTypeEnum.wordMeaning) { + choice.form.cId.setUserLemmaInfo( + UserSetLemmaInfo(meaning: choice.choiceContent), + ); + } + } + + notifyListeners(); + } +} diff --git a/lib/pangea/toolbar/widgets/practice_mode_buttons.dart b/lib/pangea/toolbar/widgets/practice_mode_buttons.dart deleted file mode 100644 index de58c3963..000000000 --- a/lib/pangea/toolbar/widgets/practice_mode_buttons.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart'; - -class PracticeModeButtons extends StatelessWidget { - final MessageOverlayController overlayController; - - const PracticeModeButtons({ - required this.overlayController, - super.key, - }); - - static const double iconWidth = 36.0; - static const double buttonSize = 40.0; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - spacing: 4.0, - children: [ - Container( - width: buttonSize + 4, - height: buttonSize + 4, - alignment: Alignment.center, - child: ToolbarButton( - mode: MessageMode.listening, - overlayController: overlayController, - buttonSize: buttonSize, - ), - ), - Container( - width: buttonSize + 4, - height: buttonSize + 4, - alignment: Alignment.center, - child: ToolbarButton( - mode: MessageMode.wordMorph, - overlayController: overlayController, - buttonSize: buttonSize, - ), - ), - Container( - width: buttonSize + 4, - height: buttonSize + 4, - alignment: Alignment.center, - child: ToolbarButton( - mode: MessageMode.wordMeaning, - overlayController: overlayController, - buttonSize: buttonSize, - ), - ), - Container( - width: buttonSize + 4, - height: buttonSize + 4, - alignment: Alignment.center, - child: ToolbarButton( - mode: MessageMode.wordEmoji, - overlayController: overlayController, - buttonSize: buttonSize, - ), - ), - ], - ), - const SizedBox(height: 4.0), - ], - ); - } -} diff --git a/lib/pangea/toolbar/widgets/practice_mode_transition_animation.dart b/lib/pangea/toolbar/widgets/practice_mode_transition_animation.dart index 6f342e574..3f9b14320 100644 --- a/lib/pangea/toolbar/widgets/practice_mode_transition_animation.dart +++ b/lib/pangea/toolbar/widgets/practice_mode_transition_animation.dart @@ -27,8 +27,6 @@ class PracticeModeTransitionAnimationState Animation? _offsetAnimation; Animation? _sizeAnimation; - bool _finishedAnimation = false; - RenderBox? get _centerMessageRenderBox { try { return MatrixState.pAnyState.getRenderBox(widget.targetId); @@ -72,7 +70,6 @@ class PracticeModeTransitionAnimationState _animationController = AnimationController( vsync: this, duration: widget.controller.transitionAnimationDuration, - // duration: const Duration(seconds: 3), ); _offsetAnimation = Tween( @@ -101,13 +98,10 @@ class PracticeModeTransitionAnimationState ); widget.controller.onStartedTransition(); + setState(() {}); + _animationController!.forward().then((_) { widget.controller.onFinishedTransition(); - if (mounted) { - setState(() { - _finishedAnimation = true; - }); - } }); }); } @@ -120,81 +114,94 @@ class PracticeModeTransitionAnimationState @override Widget build(BuildContext context) { - if (_offsetAnimation == null || _finishedAnimation) { - return const SizedBox(); - } - - return AnimatedBuilder( - animation: _offsetAnimation!, - builder: (context, child) { - return Positioned( - top: _offsetAnimation!.value.dy, - left: - widget.controller.ownMessage ? null : _offsetAnimation!.value.dx, - right: - widget.controller.ownMessage ? _offsetAnimation!.value.dx : null, - child: OverlayCenterContent( - event: widget.controller.widget.event, - overlayController: widget.controller.widget.overlayController, - chatController: widget.controller.widget.chatController, - nextEvent: widget.controller.widget.nextEvent, - prevEvent: widget.controller.widget.prevEvent, - hasReactions: widget.controller.hasReactions, - sizeAnimation: _sizeAnimation, - readingAssistanceMode: widget.controller.readingAssistanceMode, - ), - ); + return ValueListenableBuilder( + valueListenable: widget.controller.finishedTransition, + child: _offsetAnimation == null + ? const SizedBox() + : AnimatedBuilder( + animation: _offsetAnimation!, + builder: (context, child) { + return Positioned( + top: _offsetAnimation!.value.dy, + left: widget.controller.ownMessage + ? null + : _offsetAnimation!.value.dx, + right: widget.controller.ownMessage + ? _offsetAnimation!.value.dx + : null, + child: OverlayCenterContent( + event: widget.controller.widget.event, + overlayController: + widget.controller.widget.overlayController, + chatController: widget.controller.widget.chatController, + nextEvent: widget.controller.widget.nextEvent, + prevEvent: widget.controller.widget.prevEvent, + hasReactions: widget.controller.hasReactions, + sizeAnimation: _sizeAnimation, + readingAssistanceMode: + widget.controller.readingAssistanceMode, + overlayKey: + "overlay_transition_message_${widget.controller.widget.event.eventId}", + ), + ); + }, + ), + builder: (context, finished, child) { + if (finished || _offsetAnimation == null) { + return const SizedBox(); + } + return child!; }, ); } } class CenteredMessage extends StatelessWidget { - final String targetId; final MessageSelectionPositionerState controller; const CenteredMessage({ super.key, - required this.targetId, required this.controller, }); @override Widget build(BuildContext context) { - return Opacity( - opacity: controller.finishedTransition ? 1.0 : 0.0, - child: GestureDetector( - onTap: controller.widget.chatController.clearSelectedEvents, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: - controller.mediaQuery!.size.width - controller.columnWidth, - height: 20.0, + return ValueListenableBuilder( + valueListenable: controller.finishedTransition, + builder: (context, finished, __) { + return Opacity( + opacity: finished ? 1.0 : 0.0, + child: GestureDetector( + onTap: controller.widget.chatController.clearSelectedEvents, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: controller.mediaQuery!.size.width - + controller.columnWidth, + height: 20.0, + ), + OverlayCenterContent( + event: controller.widget.event, + overlayController: controller.widget.overlayController, + chatController: controller.widget.chatController, + nextEvent: controller.widget.nextEvent, + prevEvent: controller.widget.prevEvent, + hasReactions: controller.hasReactions, + overlayKey: + "overlay_center_message_${controller.widget.event.eventId}", + readingAssistanceMode: controller.readingAssistanceMode, + ), + const SizedBox( + height: AppConfig.readingAssistanceInputBarHeight + 60.0, + ), + ], ), - OverlayCenterContent( - event: controller.widget.event, - overlayController: controller.widget.overlayController, - chatController: controller.widget.chatController, - nextEvent: controller.widget.nextEvent, - prevEvent: controller.widget.prevEvent, - hasReactions: controller.hasReactions, - overlayKey: MatrixState.pAnyState - .layerLinkAndKey( - "overlay_center_message_${controller.widget.event.eventId}", - ) - .key, - readingAssistanceMode: controller.readingAssistanceMode, - ), - const SizedBox( - height: AppConfig.readingAssistanceInputBarHeight + 60.0, - ), - ], + ), ), - ), - ), + ); + }, ); } } diff --git a/lib/pangea/toolbar/widgets/toolbar_button.dart b/lib/pangea/toolbar/widgets/toolbar_button.dart index 4da87573b..79a6599ed 100644 --- a/lib/pangea/toolbar/widgets/toolbar_button.dart +++ b/lib/pangea/toolbar/widgets/toolbar_button.dart @@ -1,50 +1,51 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; class ToolbarButton extends StatelessWidget { final MessageMode mode; - final MessageOverlayController overlayController; - final double buttonSize; + final VoidCallback setMode; + + final bool isComplete; + final bool isSelected; const ToolbarButton({ required this.mode, - required this.overlayController, - required this.buttonSize, + required this.setMode, + required this.isComplete, + required this.isSelected, super.key, }); - Color color(BuildContext context) => mode.iconButtonColor( - context, - overlayController, - ); - @override Widget build(BuildContext context) { - return Tooltip( - message: mode.tooltip(context), - child: PressableButton( - borderRadius: BorderRadius.circular(20), - depressed: mode == overlayController.toolbarMode, - color: color(context), - onPressed: () => overlayController.updateToolbarMode(mode), - playSound: true, - colorFactor: - Theme.of(context).brightness == Brightness.light ? 0.55 : 0.3, - child: AnimatedContainer( - duration: FluffyThemes.animationDuration, - height: buttonSize, - width: buttonSize, - decoration: BoxDecoration( - color: color(context), - shape: BoxShape.circle, - ), - child: Icon( - mode.icon, - size: 20, + final color = mode.iconButtonColor(context, isComplete); + return Container( + width: 44.0, + height: 44.0, + alignment: Alignment.center, + child: Tooltip( + message: mode.tooltip(context), + child: PressableButton( + borderRadius: BorderRadius.circular(20), + depressed: isSelected, + color: color, + onPressed: setMode, + playSound: true, + colorFactor: + Theme.of(context).brightness == Brightness.light ? 0.55 : 0.3, + child: Container( + height: 40.0, + width: 40.0, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + mode.icon, + size: 20, + ), ), ), ),