From 1317989db049cb1474de934b813dee5eca17ecbc Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 27 Dec 2024 12:42:49 -0500 Subject: [PATCH] 1179 toolbar changes (#1209) * updated toolbar buttons * initial work for toolbar updates * Add WordZoomWidget to display word and lemma information (#1214) --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/pangeachat/client/tree/1179-toolbar-changes?shareId=XXXX-XXXX-XXXX-XXXX). * word zoom card prototyped, activity generation in progress * adding copy for new construct uses * laying down TODOs * initial work for word zoom card * Always add part of speech to token's morph list * Prevent duplicate choices in lemma activity * Don't play token audio at start of morph activity * Only grant +1 points for emoji activity * Uncomment tryToSpeak function * Always update activity once complete * Added queuing / UI logic for morph activity buttons * code cleanup * added required data argument to logError calls * fix overflowing practice activity card and audio player on mobile --------- Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> --- assets/l10n/intl_en.arb | 7 + lib/pages/chat/chat_input_row.dart | 9 +- lib/pages/chat/events/audio_player.dart | 26 +- .../choreographer/widgets/choice_array.dart | 10 +- lib/pangea/constants/model_keys.dart | 1 + lib/pangea/constants/pangea_event_types.dart | 1 + .../message_analytics_controller.dart | 164 ++++---- lib/pangea/controllers/pangea_controller.dart | 3 - .../text_to_speech_controller.dart | 2 +- lib/pangea/enum/activity_type_enum.dart | 80 +++- lib/pangea/enum/construct_use_type_enum.dart | 133 +++--- lib/pangea/enum/message_mode_enum.dart | 38 +- .../analytics/construct_list_model.dart | 89 ++++ lib/pangea/models/pangea_token_model.dart | 356 +++++++++++----- .../models/pangea_token_text_model.dart | 45 +++ .../message_activity_request.dart | 9 +- .../multiple_choice_activity_model.dart | 43 +- .../practice_activity_model.dart | 3 +- .../practice_activity_record_model.dart | 29 ++ lib/pangea/network/urls.dart | 7 +- .../repo/contextualized_translation_repo.dart | 2 +- lib/pangea/repo/igc_repo.dart | 1 + lib/pangea/repo/image_repo.dart | 1 + lib/pangea/repo/lemma_definition_repo.dart | 147 +++++++ .../practice/emoji_activity_generator.dart | 36 ++ .../practice/lemma_activity_generator.dart | 37 ++ .../practice/morph_activity_generator.dart | 100 +++++ .../practice/practice_repo.dart} | 65 ++- .../utils/grammar/get_grammar_copy.dart | 5 +- .../widgets/chat/message_audio_card.dart | 4 +- .../chat/message_selection_overlay.dart | 180 ++++----- lib/pangea/widgets/chat/message_toolbar.dart | 131 ++---- .../widgets/chat/message_toolbar_buttons.dart | 139 ++++--- .../chat/message_translation_card.dart | 2 +- lib/pangea/widgets/chat/overlay_footer.dart | 6 +- .../widgets/chat/overlay_message_text.dart | 189 --------- .../widgets/chat/pangea_reaction_picker.dart | 36 +- lib/pangea/widgets/chat/tts_controller.dart | 49 ++- lib/pangea/widgets/igc/word_data_card.dart | 103 ++--- .../emoji_practice_button.dart | 63 +++ .../multiple_choice_activity.dart | 86 ++-- .../practice_activity_card.dart | 381 ++++++++---------- .../word_text_with_audio_button.dart | 102 +++++ .../contextual_translation_widget.dart | 87 ++++ .../word_zoom/lemma_definition_widget.dart | 70 ++++ .../widgets/word_zoom/lemma_widget.dart | 35 ++ .../word_zoom/morphological_widget.dart | 236 +++++++++++ .../word_zoom/part_of_speech_widget.dart | 51 +++ .../widgets/word_zoom/word_zoom_widget.dart | 289 +++++++++++++ lib/utils/client_manager.dart | 1 + pubspec.lock | 8 + pubspec.yaml | 1 + 52 files changed, 2598 insertions(+), 1100 deletions(-) create mode 100644 lib/pangea/models/pangea_token_text_model.dart create mode 100644 lib/pangea/repo/lemma_definition_repo.dart create mode 100644 lib/pangea/repo/practice/emoji_activity_generator.dart create mode 100644 lib/pangea/repo/practice/lemma_activity_generator.dart create mode 100644 lib/pangea/repo/practice/morph_activity_generator.dart rename lib/pangea/{controllers/practice_activity_generation_controller.dart => repo/practice/practice_repo.dart} (63%) delete mode 100644 lib/pangea/widgets/chat/overlay_message_text.dart create mode 100644 lib/pangea/widgets/practice_activity/emoji_practice_button.dart create mode 100644 lib/pangea/widgets/practice_activity/word_text_with_audio_button.dart create mode 100644 lib/pangea/widgets/word_zoom/contextual_translation_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/lemma_definition_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/lemma_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/morphological_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/part_of_speech_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/word_zoom_widget.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 43a159f2a..78e723f40 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4615,6 +4615,13 @@ "constructUseCorHWLDesc": "Correct in hidden word activity", "constructUseIncHWLDesc": "Incorrect in hidden word activity", "constructUseIgnHWLDesc": "Ignored in hidden word activity", + "constructUseCorLDesc": "Correct in lemma activity", + "constructUseIncLDesc": "Incorrect in lemma activity", + "constructUseIgnLDesc": "Ignored in lemma activity", + "constructUseCorMDesc": "Correct in grammar activity", + "constructUseIncMDesc": "Incorrect in grammar activity", + "constructUseIgnMDesc": "Ignored in grammar activity", + "constructUseEmojiDesc": "Correct in emoji activity", "constructUseNanDesc": "Not applicable", "xpIntoLevel": "{currentXP} / {maxXP} XP", "@xpIntoLevel": { diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index f08e15d38..f98a5f9ff 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -2,6 +2,7 @@ import 'package:animations/animations.dart'; import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart'; import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/constants/language_constants.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/pangea_reaction_picker.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -19,13 +20,13 @@ import 'input_bar.dart'; class ChatInputRow extends StatelessWidget { final ChatController controller; // #Pangea - final bool isOverlay; + final MessageOverlayController? overlayController; // Pangea# const ChatInputRow( this.controller, { // #Pangea - this.isOverlay = false, + this.overlayController, // Pangea# super.key, }); @@ -68,7 +69,7 @@ class ChatInputRow extends StatelessWidget { CompositedTransformTarget( link: controller.choreographer.inputLayerLinkAndKey.link, child: Row( - key: isOverlay + key: overlayController != null ? null : controller.choreographer.inputLayerLinkAndKey.key, // crossAxisAlignment: CrossAxisAlignment.end, @@ -154,7 +155,7 @@ class ChatInputRow extends StatelessWidget { // Pangea# : const SizedBox.shrink(), // #Pangea - PangeaReactionsPicker(controller), + PangeaReactionsPicker(controller, overlayController), if (controller.selectedEvents.length == 1 && !controller.selectedEvents.first .getDisplayEvent(controller.timeline!) diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 1a5ba5017..36be146f7 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -3,7 +3,6 @@ import 'dart:developer'; import 'dart:io'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; @@ -27,6 +26,7 @@ class AudioPlayerWidget extends StatefulWidget { final PangeaAudioFile? matrixFile; final bool autoplay; final Function(bool)? setIsPlayingAudio; + final double padding; // Pangea# static String? currentId; @@ -48,6 +48,7 @@ class AudioPlayerWidget extends StatefulWidget { this.sectionStartMS, this.sectionEndMS, this.setIsPlayingAudio, + this.padding = 12.0, // Pangea# super.key, }); @@ -354,15 +355,18 @@ class AudioPlayerState extends State { return Padding( // #Pangea // padding: const EdgeInsets.all(12.0), - padding: const EdgeInsets.all(5.0), + padding: EdgeInsets.all(widget.padding), // Pangea# child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: FluffyThemes.columnWidth), + // #Pangea + // constraints: + // const BoxConstraints(maxWidth: FluffyThemes.columnWidth), + constraints: const BoxConstraints(maxWidth: 250), + // Pangea# child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -393,7 +397,9 @@ class AudioPlayerState extends State { ), ), ), - const SizedBox(width: 8), + // #Pangea + // const SizedBox(width: 8), + // Pangea# Expanded( child: Stack( children: [ @@ -410,9 +416,14 @@ class AudioPlayerState extends State { height: 32, alignment: Alignment.center, child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 1, + // #Pangea + // margin: const EdgeInsets.symmetric( + // horizontal: 1, + // ), + margin: const EdgeInsets.only( + right: 0.5, ), + // Pangea# decoration: BoxDecoration( color: i < wavePosition ? widget.color @@ -453,7 +464,6 @@ class AudioPlayerState extends State { ), // #Pangea // const SizedBox(width: 8), - const SizedBox(width: 5), // SizedBox( // width: 36, // child: diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index ddd190bcc..331616ec2 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -34,6 +34,8 @@ class ChoicesArray extends StatefulWidget { /// some uses of this widget want to disable clicking of the choices final bool isActive; + final String Function(String)? getDisplayCopy; + const ChoicesArray({ super.key, required this.isLoading, @@ -46,6 +48,7 @@ class ChoicesArray extends StatefulWidget { this.enableAudio = true, this.isActive = true, this.onLongPress, + this.getDisplayCopy, this.id, }); @@ -103,6 +106,7 @@ class ChoicesArrayState extends State { disableInteraction: disableInteraction, isSelected: widget.selectedChoiceIndex == index, id: widget.id, + getDisplayCopy: widget.getDisplayCopy, ), ) .toList(), @@ -134,6 +138,7 @@ class ChoiceItem extends StatelessWidget { required this.enableInteraction, required this.disableInteraction, required this.id, + this.getDisplayCopy, }); final MapEntry entry; @@ -145,6 +150,7 @@ class ChoiceItem extends StatelessWidget { final VoidCallback enableInteraction; final VoidCallback disableInteraction; final String? id; + final String Function(String)? getDisplayCopy; @override Widget build(BuildContext context) { @@ -201,7 +207,9 @@ class ChoiceItem extends StatelessWidget { ? null : () => onPressed(entry.value.text, entry.key), child: Text( - entry.value.text, + getDisplayCopy != null + ? getDisplayCopy!(entry.value.text) + : entry.value.text, style: BotStyle.text(context), ), ), diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index e82514bd3..f58f70755 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -137,4 +137,5 @@ class ModelKey { static const String latestVersion = "latest_version"; static const String latestBuildNumber = "latest_build_number"; static const String mandatoryUpdate = "mandatory_update"; + static const String emoji = "emoji"; } diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 9ca975dc0..0f80c1653 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -8,6 +8,7 @@ class PangeaEventTypes { // static const studentAnalyticsSummary = "pangea.usranalytics"; static const summaryAnalytics = "pangea.summaryAnalytics"; static const construct = "pangea.construct"; + static const userChosenEmoji = "p.emoji"; static const translation = "pangea.translation"; static const tokens = "pangea.tokens"; diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index ec7c9dcf0..eed61de83 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -69,7 +69,9 @@ class MessageAnalyticsEntry { late final bool _includeHiddenWordActivities; - late final List _activityQueue; + final List _activityQueue = []; + + final int _maxQueueLength = 3; MessageAnalyticsEntry({ required List tokens, @@ -77,108 +79,77 @@ class MessageAnalyticsEntry { }) { _tokens = tokens; _includeHiddenWordActivities = includeHiddenWordActivities; - _activityQueue = setActivityQueue(); + setActivityQueue(); + } + + void _pushQueue(TargetTokensAndActivityType entry) { + if (nextActivity?.activityType == ActivityTypeEnum.hiddenWordListening) { + if (entry.activityType == ActivityTypeEnum.hiddenWordListening) { + _activityQueue[0] = entry; + } else { + _activityQueue.insert(1, entry); + } + } else { + _activityQueue.insert(0, entry); + } + + if (_activityQueue.length > _maxQueueLength) { + _activityQueue.removeRange( + _maxQueueLength, + _activityQueue.length, + ); + } + } + + void _popQueue() { + if (hasHiddenWordActivity) { + _activityQueue.removeAt(0); + } + } + + void _filterQueue(ActivityTypeEnum activityType) { + _activityQueue.removeWhere((a) => a.activityType == activityType); + } + + void _clearQueue() { + _activityQueue.clear(); } TargetTokensAndActivityType? get nextActivity => _activityQueue.isNotEmpty ? _activityQueue.first : null; - /// 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 get hasHiddenWordActivity => + nextActivity?.activityType.hiddenType ?? false; + + int get numActivities => _activityQueue.length; + + // /// 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; /// On initialization, we pick which tokens to do activities on and what types of activities to do - List setActivityQueue() { + void setActivityQueue() { final List queue = []; - // for each token in the message - // pick a random activity type from the eligible types - for (final token in _tokens) { - // get all the eligible activity types for the token - // based on the context of the message - final eligibleTypesBasedOnContext = token.eligibleActivityTypes - // we want to filter hidden word types from this part of the process - .where((type) => type != ActivityTypeEnum.hiddenWordListening) - // there have to be at least 4 tokens in the message that can be heard for word focus listening - .where( - (type) => - canDoWordFocusListening || - type != ActivityTypeEnum.wordFocusListening, - ) - .toList(); - - // if there are no eligible types, continue to the next token - if (eligibleTypesBasedOnContext.isEmpty) continue; - - // chose a random activity type from the eligible types for that token - queue.add( - TargetTokensAndActivityType( - tokens: [token], - activityType: eligibleTypesBasedOnContext[ - Random().nextInt(eligibleTypesBasedOnContext.length)], - ), - ); - } - - // sort the queue by the total xp of the tokens, lowest first - queue.sort( - (a, b) => a.tokens - .map((t) => t.vocabConstruct.points) - .reduce((a, b) => a + b) - .compareTo( - b.tokens - .map((t) => t.vocabConstruct.points) - .reduce((a, b) => a + b), - ), - ); - // if applicable, add a hidden word activity to the front of the queue final hiddenWordActivity = getHiddenWordActivity(queue.length); if (hiddenWordActivity != null) { - queue.insert(0, hiddenWordActivity); + _pushQueue(hiddenWordActivity); } - - // limit to 3 activities - final limited = queue.take(3).toList(); - - // debugPrint("activities for ${PangeaToken.reconstructText(_tokens)}"); - // for (final activity in limited) { - // debugPrint("activity: ${activity.activityType}"); - // for (final token in activity.tokens) { - // debugPrint("token: ${token.analyticsDebugPrint}"); - // } - // } - - return limited; } /// Adds a word focus listening activity to the front of the queue - /// And limits to 3 activities - void addForWordMeaning(PangeaToken selectedToken) { - final int index = _activityQueue.isNotEmpty && - _activityQueue.first.activityType == - ActivityTypeEnum.hiddenWordListening - ? 1 - : 0; - - _activityQueue.insert( - index, - TargetTokensAndActivityType( - tokens: [selectedToken], - activityType: ActivityTypeEnum.wordMeaning, - ), + /// And limits to _maxQueueLength activities + void addTokenToActivityQueue( + PangeaToken token, { + ActivityTypeEnum type = ActivityTypeEnum.wordMeaning, + }) { + final entry = TargetTokensAndActivityType( + tokens: [token], + activityType: ActivityTypeEnum.wordMeaning, ); - // remove down to three activities - if (_activityQueue.length > 3) { - _activityQueue.removeRange(3, _activityQueue.length); - } - } - - int get numActivities => _activityQueue.length; - - void clearActivityQueue() { - _activityQueue.clear(); + _pushQueue(entry); } /// Returns a hidden word activity if there is a sequence of tokens that have hiddenWordListening in their eligibleActivityTypes @@ -190,7 +161,7 @@ class MessageAnalyticsEntry { // we will only do hidden word listening 50% of the time // if there are no other activities to do, we will always do hidden word listening - if (numOtherActivities >= 3 && Random().nextDouble() < 0.5) { + if (numOtherActivities >= _maxQueueLength && Random().nextDouble() < 0.5) { return null; } @@ -198,8 +169,11 @@ class MessageAnalyticsEntry { final List> sequences = []; List currentSequence = []; for (final token in _tokens) { - if (token.eligibleActivityTypes - .contains(ActivityTypeEnum.hiddenWordListening)) { + if (token.shouldDoActivity( + a: ActivityTypeEnum.hiddenWordListening, + feature: null, + tag: null, + )) { currentSequence.add(token); } else { if (currentSequence.isNotEmpty) { @@ -226,15 +200,13 @@ class MessageAnalyticsEntry { ); } - void onActivityComplete(PracticeActivityModel completed) { - _activityQueue.removeWhere( - (a) => a.matchesActivity(completed), - ); + void onActivityComplete() { + _popQueue(); } - void revealAllTokens() { - _activityQueue.removeWhere((a) => a.activityType.hiddenType); - } + void exitPracticeFlow() => _clearQueue(); + + void revealAllTokens() => _filterQueue(ActivityTypeEnum.hiddenWordListening); bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any( (activity) => diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index ffddb2265..26f06503e 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -13,7 +13,6 @@ import 'package:fluffychat/pangea/controllers/language_detection_controller.dart import 'package:fluffychat/pangea/controllers/language_list_controller.dart'; import 'package:fluffychat/pangea/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/controllers/permissions_controller.dart'; -import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart'; import 'package:fluffychat/pangea/controllers/practice_activity_record_controller.dart'; import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/speech_to_text_controller.dart'; @@ -57,7 +56,6 @@ class PangeaController { late SpeechToTextController speechToText; late LanguageDetectionController languageDetection; late PracticeActivityRecordController activityRecordController; - late PracticeGenerationController practiceGenerationController; ///store Services late PStore pStoreService; @@ -114,7 +112,6 @@ class PangeaController { speechToText = SpeechToTextController(this); languageDetection = LanguageDetectionController(this); activityRecordController = PracticeActivityRecordController(this); - practiceGenerationController = PracticeGenerationController(this); PAuthGaurd.pController = this; } diff --git a/lib/pangea/controllers/text_to_speech_controller.dart b/lib/pangea/controllers/text_to_speech_controller.dart index 9d409c515..819f29f89 100644 --- a/lib/pangea/controllers/text_to_speech_controller.dart +++ b/lib/pangea/controllers/text_to_speech_controller.dart @@ -5,7 +5,7 @@ import 'dart:typed_data'; import 'package:fluffychat/pangea/config/environment.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/network/urls.dart'; import 'package:http/http.dart'; diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart index d7f7d9df5..c7f9ba36e 100644 --- a/lib/pangea/enum/activity_type_enum.dart +++ b/lib/pangea/enum/activity_type_enum.dart @@ -1,8 +1,17 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; -enum ActivityTypeEnum { wordMeaning, wordFocusListening, hiddenWordListening } +enum ActivityTypeEnum { + wordMeaning, + wordFocusListening, + hiddenWordListening, + lemmaId, + emoji, + morphId +} extension ActivityTypeExtension on ActivityTypeEnum { String get string { @@ -13,6 +22,12 @@ extension ActivityTypeExtension on ActivityTypeEnum { return 'word_focus_listening'; case ActivityTypeEnum.hiddenWordListening: return 'hidden_word_listening'; + case ActivityTypeEnum.lemmaId: + return 'lemma_id'; + case ActivityTypeEnum.emoji: + return 'emoji'; + case ActivityTypeEnum.morphId: + return 'morph_id'; } } @@ -20,6 +35,9 @@ extension ActivityTypeExtension on ActivityTypeEnum { switch (this) { case ActivityTypeEnum.wordMeaning: case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.morphId: return false; case ActivityTypeEnum.hiddenWordListening: return true; @@ -29,6 +47,9 @@ extension ActivityTypeExtension on ActivityTypeEnum { bool get includeTTSOnClick { switch (this) { case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.morphId: return false; case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: @@ -53,6 +74,12 @@ extension ActivityTypeExtension on ActivityTypeEnum { case 'hidden_word_listening': case 'hiddenWordListening': return ActivityTypeEnum.hiddenWordListening; + case 'lemma_id': + return ActivityTypeEnum.lemmaId; + case 'emoji': + return ActivityTypeEnum.emoji; + case 'morph_id': + return ActivityTypeEnum.morphId; default: throw Exception('Unknown activity type: $split'); } @@ -78,6 +105,37 @@ extension ActivityTypeExtension on ActivityTypeEnum { ConstructUseTypeEnum.incHWL, ConstructUseTypeEnum.ignHWL, ]; + case ActivityTypeEnum.lemmaId: + return [ + ConstructUseTypeEnum.corL, + ConstructUseTypeEnum.incL, + ConstructUseTypeEnum.ignL, + ]; + case ActivityTypeEnum.emoji: + return [ConstructUseTypeEnum.em]; + case ActivityTypeEnum.morphId: + return [ + ConstructUseTypeEnum.corM, + ConstructUseTypeEnum.incM, + ConstructUseTypeEnum.ignM, + ]; + } + } + + ConstructUseTypeEnum get correctUse { + switch (this) { + case ActivityTypeEnum.wordMeaning: + return ConstructUseTypeEnum.corPA; + case ActivityTypeEnum.wordFocusListening: + return ConstructUseTypeEnum.corWL; + case ActivityTypeEnum.hiddenWordListening: + return ConstructUseTypeEnum.corHWL; + case ActivityTypeEnum.lemmaId: + return ConstructUseTypeEnum.corL; + case ActivityTypeEnum.emoji: + return ConstructUseTypeEnum.em; + case ActivityTypeEnum.morphId: + return ConstructUseTypeEnum.corM; } } @@ -87,7 +145,27 @@ extension ActivityTypeExtension on ActivityTypeEnum { case ActivityTypeEnum.wordMeaning: case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: return (id) => id.type == ConstructTypeEnum.vocab; + case ActivityTypeEnum.morphId: + return (id) => id.type == ConstructTypeEnum.morph; + } + } + + IconData get icon { + switch (this) { + case ActivityTypeEnum.wordMeaning: + return Icons.translate; + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.hiddenWordListening: + return Icons.hearing; + case ActivityTypeEnum.lemmaId: + return Symbols.dictionary; + case ActivityTypeEnum.emoji: + return Icons.emoji_emotions; + case ActivityTypeEnum.morphId: + return Icons.format_shapes; } } } diff --git a/lib/pangea/enum/construct_use_type_enum.dart b/lib/pangea/enum/construct_use_type_enum.dart index ccce37f9e..504973110 100644 --- a/lib/pangea/enum/construct_use_type_enum.dart +++ b/lib/pangea/enum/construct_use_type_enum.dart @@ -1,3 +1,7 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -12,52 +16,46 @@ enum ConstructUseTypeEnum { /// produced in chat by user and igc was not run unk, - /// selected correctly in IT flow + /// interactive translation activity corIt, - - /// encountered as IT distractor and correctly ignored it ignIt, - - /// encountered as it distractor and selected it incIt, - /// encountered in igc match and ignored match + /// interactive grammar checking activity + corIGC, + incIGC, ignIGC, - /// selected correctly in IGC flow - corIGC, - - /// encountered as distractor in IGC flow and selected it - incIGC, - - /// selected correctly in word meaning in context practice activity + /// word meaning in context practice activity corPA, - - /// encountered as distractor in word meaning in context practice activity and correctly ignored it - /// Currently not used ignPA, - - /// was target construct in word meaning in context practice activity and incorrectly selected incPA, - /// was target lemma in word-focus listening activity and correctly selected + /// applies to target lemma in word-focus listening activity corWL, - - /// a form of lemma was read-aloud in word-focus listening activity and incorrectly selected incWL, - - /// a form of the lemma was read-aloud in word-focus listening activity and correctly ignored ignWL, - /// correctly chose a form of the lemma in a hidden word listening activity + /// applies to the form of the lemma in a hidden word listening activity corHWL, - - /// incorrectly chose a form of the lemma in a hidden word listening activity incHWL, - - /// ignored a form of the lemma in a hidden word listening activity ignHWL, + /// lemma id activity + corL, + incL, + ignL, + + /// morph id activity + corM, + incM, + ignM, + + /// emoji activity + /// No correct/incorrect/ignored distinction is made + /// User can select any emoji + em, + /// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client nan } @@ -103,52 +101,60 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return L10n.of(context).constructUseIncHWLDesc; case ConstructUseTypeEnum.ignHWL: return L10n.of(context).constructUseIgnHWLDesc; + case ConstructUseTypeEnum.corL: + return L10n.of(context).constructUseCorLDesc; + case ConstructUseTypeEnum.incL: + return L10n.of(context).constructUseIncLDesc; + case ConstructUseTypeEnum.ignL: + return L10n.of(context).constructUseIgnLDesc; + case ConstructUseTypeEnum.corM: + return L10n.of(context).constructUseCorMDesc; + case ConstructUseTypeEnum.incM: + return L10n.of(context).constructUseIncMDesc; + case ConstructUseTypeEnum.ignM: + return L10n.of(context).constructUseIgnMDesc; + case ConstructUseTypeEnum.em: + return L10n.of(context).constructUseEmojiDesc; case ConstructUseTypeEnum.nan: return L10n.of(context).constructUseNanDesc; } } + ActivityTypeEnum get activityType => ActivityTypeEnum.values.firstWhere( + (e) => e.associatedUseTypes.contains(this), + orElse: () { + debugger(when: kDebugMode); + return ActivityTypeEnum.wordMeaning; + }, + ); + IconData get icon { switch (this) { - // all minus for wrong answer - case ConstructUseTypeEnum.incIt: - case ConstructUseTypeEnum.incIGC: - case ConstructUseTypeEnum.incPA: - case ConstructUseTypeEnum.incWL: - case ConstructUseTypeEnum.incHWL: - return Icons.dangerous_outlined; - - // correct in word meaning - case ConstructUseTypeEnum.corPA: - return Icons.add_task_outlined; - - // correct in audio practice - case ConstructUseTypeEnum.corWL: - case ConstructUseTypeEnum.corHWL: - return Icons.volume_up_outlined; - - // correct in translation - case ConstructUseTypeEnum.corIt: - return Icons.translate_outlined; - - // written correctly without help case ConstructUseTypeEnum.wa: - return Icons.thumb_up_outlined; - - // correct in grammar correction - case ConstructUseTypeEnum.corIGC: - return Icons.spellcheck_outlined; - - // ignored + case ConstructUseTypeEnum.corIt: + case ConstructUseTypeEnum.incIt: case ConstructUseTypeEnum.ignIt: case ConstructUseTypeEnum.ignIGC: + case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.incPA: case ConstructUseTypeEnum.ignPA: case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.incHWL: case ConstructUseTypeEnum.ignHWL: - return Icons.block_outlined; - case ConstructUseTypeEnum.ga: - return Icons.edit_outlined; + case ConstructUseTypeEnum.corIGC: + case ConstructUseTypeEnum.corPA: + case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.corHWL: + case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.ignL: + case ConstructUseTypeEnum.corM: + case ConstructUseTypeEnum.incM: + case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.em: + return activityType.icon; case ConstructUseTypeEnum.unk: case ConstructUseTypeEnum.nan: @@ -173,9 +179,12 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return 3; case ConstructUseTypeEnum.corIGC: + case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.corM: return 2; case ConstructUseTypeEnum.corIt: + case ConstructUseTypeEnum.em: return 1; case ConstructUseTypeEnum.ignIt: @@ -183,6 +192,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignPA: case ConstructUseTypeEnum.ignWL: case ConstructUseTypeEnum.ignHWL: + case ConstructUseTypeEnum.ignL: + case ConstructUseTypeEnum.ignM: case ConstructUseTypeEnum.unk: case ConstructUseTypeEnum.nan: return 0; @@ -192,6 +203,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incIt: case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.incM: return -2; case ConstructUseTypeEnum.incPA: diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index ef39223a4..ccd6ac238 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -6,9 +6,10 @@ import 'package:matrix/matrix.dart'; enum MessageMode { practiceActivity, textToSpeech, - definition, translation, speechToText, + wordZoom, + noneSelected, } extension MessageModeExtension on MessageMode { @@ -20,13 +21,12 @@ extension MessageModeExtension on MessageMode { return Symbols.text_to_speech; case MessageMode.speechToText: return Symbols.speech_to_text; - //TODO change icon for audio messages - case MessageMode.definition: - return Icons.book; case MessageMode.practiceActivity: return Symbols.fitness_center; - default: - return Icons.error; // Icon to indicate an error or unsupported mode + case MessageMode.wordZoom: + return Symbols.dictionary; + case MessageMode.noneSelected: + return Icons.error; } } @@ -38,13 +38,12 @@ extension MessageModeExtension on MessageMode { return L10n.of(context).messageAudio; case MessageMode.speechToText: return L10n.of(context).speechToTextTooltip; - case MessageMode.definition: - return L10n.of(context).definitions; case MessageMode.practiceActivity: return L10n.of(context).practice; - default: - return L10n.of(context) - .oopsSomethingWentWrong; // Title to indicate an error or unsupported mode + case MessageMode.wordZoom: + return L10n.of(context).vocab; + case MessageMode.noneSelected: + return ''; } } @@ -56,28 +55,27 @@ extension MessageModeExtension on MessageMode { return L10n.of(context).audioTooltip; case MessageMode.speechToText: return L10n.of(context).speechToTextTooltip; - case MessageMode.definition: - return L10n.of(context).define; case MessageMode.practiceActivity: return L10n.of(context).practice; - default: - return L10n.of(context) - .oopsSomethingWentWrong; // Title to indicate an error or unsupported mode + case MessageMode.wordZoom: + return L10n.of(context).vocab; + case MessageMode.noneSelected: + return ''; } } bool shouldShowAsToolbarButton(Event event) { switch (this) { case MessageMode.translation: - return event.messageType == MessageTypes.Text; case MessageMode.textToSpeech: return event.messageType == MessageTypes.Text; - case MessageMode.definition: - return event.messageType == MessageTypes.Text; case MessageMode.speechToText: return event.messageType == MessageTypes.Audio; case MessageMode.practiceActivity: return true; + case MessageMode.wordZoom: + case MessageMode.noneSelected: + return false; } } @@ -88,6 +86,8 @@ extension MessageModeExtension on MessageMode { ) => numActivitiesCompleted >= index || totallyDone; + bool get showButton => this != MessageMode.practiceActivity; + Color iconButtonColor( BuildContext context, int index, diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 939b38958..5a355b3bb 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -4,8 +4,10 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; /// A wrapper around a list of [OneConstructUse]s, used to simplify /// the process of filtering / sorting / displaying the events. @@ -219,4 +221,91 @@ class ConstructListModel { }).where((entry) => entry.value.isNotEmpty), ); } + + List morphActivityDistractors( + String morphFeature, + String morphTag, + ) { + final List morphConstructs = constructList( + type: ConstructTypeEnum.morph, + ); + + final List possibleDistractors = morphConstructs + .where( + (c) => + c.category == morphFeature.toLowerCase() && + c.lemma.toLowerCase() != morphTag.toLowerCase(), + ) + .map((c) => c.lemma) + .toList(); + + possibleDistractors.shuffle(); + return possibleDistractors.take(3).toList(); + } + + Future> lemmaActivityDistractors(PangeaToken token) async { + final List lemmas = 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.contains(token.lemma.text)) { + final random = Random(); + choices[random.nextInt(4)] = token.lemma.text; + } + 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/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index 694c272ff..546103025 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -1,14 +1,21 @@ import 'dart:developer'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/constants/language_constants.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; import '../constants/model_keys.dart'; import 'lemma.dart'; @@ -24,16 +31,16 @@ class PangeaToken { /// https://universaldependencies.org/u/pos/ final String pos; - /// [morph] ex {} - morphological features of the token + /// [_morph] ex {} - morphological features of the token /// https://universaldependencies.org/u/feat/ - final Map morph; + final Map _morph; PangeaToken({ required this.text, required this.lemma, required this.pos, - required this.morph, - }); + required Map morph, + }) : _morph = morph; @override bool operator ==(Object other) { @@ -47,6 +54,15 @@ class PangeaToken { @override int get hashCode => text.content.hashCode ^ text.offset.hashCode; + Map get morph { + if (_morph.keys.map((key) => key.toLowerCase()).contains("pos")) { + return _morph; + } + final morphWithPos = Map.from(_morph); + morphWithPos["pos"] = pos; + return morphWithPos; + } + /// reconstructs the text from the tokens /// [tokens] - the tokens to reconstruct /// [debugWalkThrough] - if true, will start the debugger @@ -196,62 +212,91 @@ class PangeaToken { switch (a) { case ActivityTypeEnum.wordMeaning: return canBeDefined; + case ActivityTypeEnum.lemmaId: + return lemma.saveVocab; + case ActivityTypeEnum.emoji: + return true; + case ActivityTypeEnum.morphId: + return morph.isNotEmpty; case ActivityTypeEnum.wordFocusListening: - return false; case ActivityTypeEnum.hiddenWordListening: return canBeHeard; } } - bool _didActivity(ActivityTypeEnum a) { + 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: - return vocabConstruct.uses - .map((u) => u.useType) - .any((u) => a.associatedUseTypes.contains(u)); case ActivityTypeEnum.wordFocusListening: - return vocabConstruct.uses - // TODO - double-check that form is going to be available here - // .where((u) => - // u.form?.toLowerCase() == text.content.toLowerCase(),) - .map((u) => u.useType) - .any((u) => a.associatedUseTypes.contains(u)); case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: return vocabConstruct.uses - // TODO - double-check that form is going to be available here - // .where((u) => - // u.form?.toLowerCase() == text.content.toLowerCase(),) .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) { + bool _didActivitySuccessfully( + ActivityTypeEnum a, [ + String? morphFeature, + String? morphTag, + ]) { + if ((morphFeature == null || morphTag == null) && + a == ActivityTypeEnum.morphId) { + debugger(when: kDebugMode); + return true; + } switch (a) { case ActivityTypeEnum.wordMeaning: - return vocabConstruct.uses - .map((u) => u.useType) - .any((u) => u == ConstructUseTypeEnum.corPA); case ActivityTypeEnum.wordFocusListening: - return vocabConstruct.uses - // TODO - double-check that form is going to be available here - // .where((u) => - // u.form?.toLowerCase() == text.content.toLowerCase(),) - .map((u) => u.useType) - .any((u) => u == ConstructUseTypeEnum.corWL); case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: return vocabConstruct.uses - // TODO - double-check that form is going to be available here - // .where((u) => - // u.form?.toLowerCase() == text.content.toLowerCase(),) .map((u) => u.useType) - .any((u) => u == ConstructUseTypeEnum.corHWL); + .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: + if (morphFeature == null || morphTag == null) { + debugger(when: kDebugMode); + return false; + } + return morphConstruct(morphFeature, morphTag) + .uses + .any((u) => u.useType == a.correctUse && u.form == text.content); } } - bool _isActivityProbablyLevelAppropriate(ActivityTypeEnum a) { + bool _isActivityProbablyLevelAppropriate( + ActivityTypeEnum a, [ + String? morphFeature, + String? morphTag, + ]) { switch (a) { case ActivityTypeEnum.wordMeaning: + if (daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) < 1) { + return false; + } + if (isContentWord) { return vocabConstruct.points < 30; } else if (canBeDefined) { @@ -262,22 +307,83 @@ class PangeaToken { case ActivityTypeEnum.wordFocusListening: return !_didActivitySuccessfully(a) || daysSinceLastUseByType(a) > 30; case ActivityTypeEnum.hiddenWordListening: - return daysSinceLastUseByType(a) > 2; + return daysSinceLastUseByType(a) > 7; + case ActivityTypeEnum.lemmaId: + return daysSinceLastUseByType(a) > 7; + case ActivityTypeEnum.emoji: + return getEmoji() == null; + case ActivityTypeEnum.morphId: + if (morphFeature == null || morphTag == null) { + debugger(when: kDebugMode); + return false; + } + return daysSinceLastUseMorph(morphFeature, morphTag) > 1 && + morphConstruct(morphFeature, morphTag).points < 5; + } + } + + bool get shouldDoPosActivity => shouldDoMorphActivity("Pos"); + + bool shouldDoMorphActivity(String feature) { + return shouldDoActivity( + a: ActivityTypeEnum.morphId, + feature: feature, + tag: getMorphTag(feature), + ); + } + + /// Safely get morph tag for a given feature without regard for case + String? getMorphTag(String feature) { + if (morph.containsKey(feature)) return morph[feature]; + if (morph.containsKey(feature.toLowerCase())) { + return morph[feature.toLowerCase()]; + } + final lowerCaseEntries = morph.entries.map( + (e) => MapEntry(e.key.toLowerCase(), e.value), + ); + return lowerCaseEntries + .firstWhereOrNull( + (e) => e.key == feature.toLowerCase(), + ) + ?.value; + } + + Future canGenerateDistractors( + ActivityTypeEnum type, { + String? morphFeature, + String? morphTag, + }) async { + final constructListModel = + MatrixState.pangeaController.getAnalytics.constructListModel; + switch (type) { + case ActivityTypeEnum.lemmaId: + final distractors = + await constructListModel.lemmaActivityDistractors(this); + return distractors.isNotEmpty; + case ActivityTypeEnum.morphId: + final distractors = constructListModel.morphActivityDistractors( + morphFeature!, + morphTag!, + ); + return distractors.isNotEmpty; + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.hiddenWordListening: + return true; } } // maybe for every 5 points of xp for a particular activity, increment the days between uses by 2 - bool shouldDoActivity(ActivityTypeEnum a) => - lemma.saveVocab && - _isActivityBasicallyEligible(a) && - _isActivityProbablyLevelAppropriate(a); - - /// we try to guess if the user is click on a token specifically or clicking on a message in general - /// if we think the word might be new for the learner, then we'll assume they're clicking on the word - bool get shouldDoWordMeaningOnClick => - lemma.saveVocab && - canBeDefined && - daysSinceLastUseByType(ActivityTypeEnum.wordMeaning) > 1; + bool shouldDoActivity({ + required ActivityTypeEnum a, + required String? feature, + required String? tag, + }) { + return lemma.saveVocab && + _isActivityBasicallyEligible(a) && + _isActivityProbablyLevelAppropriate(a, feature, tag); + } List get eligibleActivityTypes { final List eligibleActivityTypes = []; @@ -287,7 +393,7 @@ class PangeaToken { } for (final type in ActivityTypeEnum.values) { - if (shouldDoActivity(type)) { + if (shouldDoActivity(a: type, feature: null, tag: null)) { eligibleActivityTypes.add(type); } } @@ -295,20 +401,37 @@ class PangeaToken { return eligibleActivityTypes; } - ConstructUses get vocabConstruct { - final vocab = constructs.firstWhereOrNull( - (element) => element.id.type == ConstructTypeEnum.vocab, - ); - if (vocab == null) { - return ConstructUses( + ConstructUses get vocabConstruct => + MatrixState.pangeaController.getAnalytics.constructListModel + .getConstructUses( + ConstructIdentifier( + lemma: lemma.text, + type: ConstructTypeEnum.morph, + category: pos, + ), + ) ?? + ConstructUses( lemma: lemma.text, - constructType: ConstructTypeEnum.vocab, + constructType: ConstructTypeEnum.morph, category: pos, uses: [], ); - } - return vocab; - } + + ConstructUses morphConstruct(String morphFeature, String morphTag) => + MatrixState.pangeaController.getAnalytics.constructListModel + .getConstructUses( + ConstructIdentifier( + lemma: morphTag, + type: ConstructTypeEnum.morph, + category: morphFeature, + ), + ) ?? + ConstructUses( + lemma: morphTag, + constructType: ConstructTypeEnum.morph, + category: morphFeature, + uses: [], + ); int get xp { return constructs.fold( @@ -337,6 +460,12 @@ class PangeaToken { return DateTime.now().difference(lastUsed).inDays; } + int daysSinceLastUseMorph(String morphFeature, String morphTag) { + final lastUsed = morphConstruct(morphFeature, morphTag).lastUsed; + if (lastUsed == null) return 1000; + return DateTime.now().difference(lastUsed).inDays; + } + List get _constructIDs { final List ids = []; ids.add( @@ -374,46 +503,91 @@ class PangeaToken { 'target_types': eligibleActivityTypes.map((e) => e.string).toList(), }; } -} -class PangeaTokenText { - int offset; - String content; - int length; + Future> getEmojiChoices() => LemmaDictionaryRepo.get( + LemmaDefinitionRequest( + lemma: lemma.text, + partOfSpeech: pos, + lemmaLang: MatrixState + .pangeaController.languageController.userL2?.langCode ?? + LanguageKeys.unknownLanguage, + userL1: MatrixState + .pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + ), + ).then((onValue) => onValue.emoji); - PangeaTokenText({ - required this.offset, - required this.content, - required this.length, - }); + ConstructIdentifier get vocabConstructID => ConstructIdentifier( + lemma: lemma.text, + type: ConstructTypeEnum.vocab, + category: pos, + ); - factory PangeaTokenText.fromJson(Map json) { - debugger(when: kDebugMode && json[_offsetKey] == null); - return PangeaTokenText( - offset: json[_offsetKey], - content: json[_contentKey], - length: json[_lengthKey] ?? (json[_contentKey] as String).length, - ); - } + Room? get analyticsRoom { + final String? l2 = + MatrixState.pangeaController.languageController.userL2?.langCode; - static const String _offsetKey = "offset"; - static const String _contentKey = "content"; - static const String _lengthKey = "length"; - - Map toJson() => - {_offsetKey: offset, _contentKey: content, _lengthKey: length}; - - //override equals and hashcode - @override - bool operator ==(Object other) { - if (other is PangeaTokenText) { - return other.offset == offset && - other.content == content && - other.length == length; + if (l2 == null) { + debugger(when: kDebugMode); + return null; } - return false; + + final Room? analyticsRoom = + MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2); + + if (analyticsRoom == null) { + debugger(when: kDebugMode); + } + + return analyticsRoom; } - @override - int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode; + /// [setEmoji] sets the emoji for the lemma + /// NOTE: assumes that the language of the lemma is the same as the user's current l2 + Future setEmoji(String emoji) async { + if (analyticsRoom == null) return; + try { + final client = MatrixState.pangeaController.matrixState.client; + final syncFuture = client.onRoomState.stream.firstWhere((event) { + return event.roomId == analyticsRoom!.id && + event.state.type == PangeaEventTypes.userChosenEmoji; + }); + client.setRoomStateWithKey( + analyticsRoom!.id, + PangeaEventTypes.userChosenEmoji, + vocabConstructID.string, + {ModelKey.emoji: emoji}, + ); + await syncFuture; + } catch (err, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: err, + data: { + "construct": vocabConstructID.string, + "emoji": emoji, + }, + s: s, + ); + } + } + + /// [getEmoji] gets the emoji for the lemma + /// NOTE: assumes that the language of the lemma is the same as the user's current l2 + String? getEmoji() { + return analyticsRoom + ?.getState(PangeaEventTypes.userChosenEmoji, vocabConstructID.string) + ?.content + .tryGet(ModelKey.emoji); + } + + String get xpEmoji { + if (xp < 5) { + return "🌱"; + } else if (xp < 10) { + return "🌿"; + } else { + return "🌺"; + } + } } diff --git a/lib/pangea/models/pangea_token_text_model.dart b/lib/pangea/models/pangea_token_text_model.dart new file mode 100644 index 000000000..ecfef47e1 --- /dev/null +++ b/lib/pangea/models/pangea_token_text_model.dart @@ -0,0 +1,45 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +class PangeaTokenText { + int offset; + String content; + int length; + + PangeaTokenText({ + required this.offset, + required this.content, + required this.length, + }); + + factory PangeaTokenText.fromJson(Map json) { + debugger(when: kDebugMode && json[_offsetKey] == null); + return PangeaTokenText( + offset: json[_offsetKey], + content: json[_contentKey], + length: json[_lengthKey] ?? (json[_contentKey] as String).length, + ); + } + + static const String _offsetKey = "offset"; + static const String _contentKey = "content"; + static const String _lengthKey = "length"; + + Map toJson() => + {_offsetKey: offset, _contentKey: content, _lengthKey: length}; + + //override equals and hashcode + @override + bool operator ==(Object other) { + if (other is PangeaTokenText) { + return other.offset == offset && + other.content == content && + other.length == length; + } + return false; + } + + @override + int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode; +} diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart index 418d7f229..80aa39348 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -54,6 +54,7 @@ class MessageActivityRequest { final List targetTokens; final ActivityTypeEnum targetType; + final String? targetMorphFeature; final ActivityQualityFeedback? activityQualityFeedback; @@ -65,6 +66,7 @@ class MessageActivityRequest { required this.activityQualityFeedback, required this.targetTokens, required this.targetType, + required this.targetMorphFeature, }) { if (targetTokens.isEmpty) { throw Exception('Target tokens must not be empty'); @@ -87,6 +89,7 @@ class MessageActivityRequest { 'activity_quality_feedback': activityQualityFeedback?.toJson(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), 'target_type': targetType.string, + 'target_morph_feature': targetMorphFeature, }; } @@ -99,7 +102,8 @@ class MessageActivityRequest { other.targetType == targetType && other.activityQualityFeedback?.feedbackText == activityQualityFeedback?.feedbackText && - const ListEquality().equals(other.targetTokens, targetTokens); + const ListEquality().equals(other.targetTokens, targetTokens) && + other.targetMorphFeature == targetMorphFeature; } @override @@ -107,7 +111,8 @@ class MessageActivityRequest { return messageText.hashCode ^ targetType.hashCode ^ activityQualityFeedback.hashCode ^ - targetTokens.hashCode; + targetTokens.hashCode ^ + targetMorphFeature.hashCode; } } diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart index f96780841..6ee6aca81 100644 --- a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; @@ -7,14 +8,16 @@ import 'package:flutter/material.dart'; class ActivityContent { final String question; + + /// choices, including the correct answer final List choices; - final String answer; + final List answers; final RelevantSpanDisplayDetails? spanDisplayDetails; ActivityContent({ required this.question, required this.choices, - required this.answer, + required this.answers, required this.spanDisplayDetails, }); @@ -25,27 +28,45 @@ class ActivityContent { if (value != choices[index]) { debugger(when: kDebugMode); } - return value == answer || index == correctAnswerIndex; + return answers.contains(value) || correctAnswerIndices.contains(index); } - bool get isValidQuestion => choices.contains(answer); + bool get isValidQuestion => choices.toSet().containsAll(answers); - int get correctAnswerIndex => choices.indexOf(answer); + 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) => - index == correctAnswerIndex ? AppConfig.success : AppConfig.warning; + Color choiceColor(int index) => correctAnswerIndices.contains(index) + ? AppConfig.success + : AppConfig.warning; factory ActivityContent.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) { + answers = [answerEntry]; + } else if (answerEntry is List) { + answers = answerEntry.map((e) => e as String).toList(); + } + return ActivityContent( question: json['question'] as String, choices: (json['choices'] as List).map((e) => e as String).toList(), - answer: json['answer'] ?? json['correct_answer'] as String, + answers: answers, spanDisplayDetails: spanDisplay, ); } @@ -54,7 +75,7 @@ class ActivityContent { return { 'question': question, 'choices': choices, - 'answer': answer, + 'answer': answers, 'span_display_details': spanDisplayDetails?.toJson(), }; } @@ -67,11 +88,11 @@ class ActivityContent { return other is ActivityContent && other.question == question && other.choices == choices && - other.answer == answer; + const ListEquality().equals(other.answers.sorted(), answers.sorted()); } @override int get hashCode { - return question.hashCode ^ choices.hashCode ^ answer.hashCode; + return question.hashCode ^ choices.hashCode ^ Object.hashAll(answers); } } diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index ae1644afe..98701ec1c 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -230,7 +230,8 @@ class PracticeActivityModel { bool get shouldPlayTargetTokens => targetTokens != null && - activityType != ActivityTypeEnum.hiddenWordListening; + activityType != ActivityTypeEnum.hiddenWordListening && + activityType != ActivityTypeEnum.morphId; factory PracticeActivityModel.fromJson(Map json) { // moving from multiple_choice to content as the key diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart index 6f33f87c2..79681baec 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -143,6 +143,16 @@ class ActivityRecordResponse { return score > 0 ? ConstructUseTypeEnum.corWL : ConstructUseTypeEnum.incWL; + case ActivityTypeEnum.emoji: + return ConstructUseTypeEnum.em; + case ActivityTypeEnum.lemmaId: + return score > 0 + ? ConstructUseTypeEnum.corL + : ConstructUseTypeEnum.incL; + case ActivityTypeEnum.morphId: + return score > 0 + ? ConstructUseTypeEnum.corM + : ConstructUseTypeEnum.incM; case ActivityTypeEnum.hiddenWordListening: return score > 0 ? ConstructUseTypeEnum.corHWL @@ -155,9 +165,28 @@ class ActivityRecordResponse { PracticeActivityModel practiceActivity, ConstructUseMetaData metadata, ) { + if (practiceActivity.activityType == ActivityTypeEnum.emoji) { + if (practiceActivity.targetTokens != null && + practiceActivity.targetTokens!.isNotEmpty) { + final token = practiceActivity.targetTokens!.first; + return [ + OneConstructUse( + lemma: token.lemma.text, + form: token.text.content, + constructType: ConstructTypeEnum.vocab, + useType: useType(practiceActivity.activityType), + metadata: metadata, + category: token.pos, + ), + ]; + } + return []; + } + if (practiceActivity.targetTokens == null) { return []; } + final uses = practiceActivity.targetTokens! .map( (token) => OneConstructUse( diff --git a/lib/pangea/network/urls.dart b/lib/pangea/network/urls.dart index 35fd2a49a..10e2162c8 100644 --- a/lib/pangea/network/urls.dart +++ b/lib/pangea/network/urls.dart @@ -20,6 +20,9 @@ class PApiUrls { static String accountEndpoint = "${Environment.choreoApi}${PApiUrls.accountPrefix}"; + /// ---------------------- Util -------------------------------------- + static String appVersion = "${PApiUrls.choreoEndpoint}/version"; + /// ---------------------- Languages -------------------------------------- static String getLanguages = "${PApiUrls.choreoEndpoint}/languages"; @@ -61,6 +64,8 @@ class PApiUrls { static String messageActivityGeneration = "${PApiUrls.choreoEndpoint}/practice"; + static String lemmaDictionary = "${PApiUrls.choreoEndpoint}/lemma_definition"; + ///-------------------------------- revenue cat -------------------------- static String rcApiV1 = "https://api.revenuecat.com/v1"; @@ -70,6 +75,4 @@ class PApiUrls { "${PApiUrls.subscriptionEndpoint}/all_products"; static String rcSubscription = "$rcApiV1/subscribers"; - - static String appVersion = "${PApiUrls.choreoEndpoint}/version"; } diff --git a/lib/pangea/repo/contextualized_translation_repo.dart b/lib/pangea/repo/contextualized_translation_repo.dart index 19b18f876..737ffd76c 100644 --- a/lib/pangea/repo/contextualized_translation_repo.dart +++ b/lib/pangea/repo/contextualized_translation_repo.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:http/http.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../config/environment.dart'; -import '../models/pangea_token_model.dart'; import '../network/requests.dart'; import '../network/urls.dart'; diff --git a/lib/pangea/repo/igc_repo.dart b/lib/pangea/repo/igc_repo.dart index af4156582..50f24eba3 100644 --- a/lib/pangea/repo/igc_repo.dart +++ b/lib/pangea/repo/igc_repo.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/models/language_detection_model.dart'; import 'package:fluffychat/pangea/models/lemma.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/repo/span_data_repo.dart'; import 'package:http/http.dart'; diff --git a/lib/pangea/repo/image_repo.dart b/lib/pangea/repo/image_repo.dart index d12f02975..07d3b7a5c 100644 --- a/lib/pangea/repo/image_repo.dart +++ b/lib/pangea/repo/image_repo.dart @@ -26,6 +26,7 @@ class GenerateImageeResponse { } factory GenerateImageeResponse.error() { + // TODO: Implement better error handling return GenerateImageeResponse( imageUrl: 'https://i.imgur.com/2L2JYqk.png', prompt: 'Error', diff --git a/lib/pangea/repo/lemma_definition_repo.dart b/lib/pangea/repo/lemma_definition_repo.dart new file mode 100644 index 000000000..20e592952 --- /dev/null +++ b/lib/pangea/repo/lemma_definition_repo.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:fluffychat/pangea/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:http/http.dart'; + +import '../config/environment.dart'; +import '../network/requests.dart'; + +class LemmaDefinitionRequest { + final String lemma; + final String partOfSpeech; + final String lemmaLang; + final String userL1; + + LemmaDefinitionRequest({ + required this.lemma, + required this.partOfSpeech, + required this.lemmaLang, + required this.userL1, + }); + + factory LemmaDefinitionRequest.fromJson(Map json) { + return LemmaDefinitionRequest( + lemma: json['lemma'] as String, + partOfSpeech: json['part_of_speech'] as String, + lemmaLang: json['lemma_lang'] as String, + userL1: json['user_l1'] as String, + ); + } + + Map toJson() { + return { + 'lemma': lemma, + 'part_of_speech': partOfSpeech, + 'lemma_lang': lemmaLang, + 'user_l1': userL1, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LemmaDefinitionRequest && + runtimeType == other.runtimeType && + lemma == other.lemma && + partOfSpeech == other.partOfSpeech && + lemmaLang == other.lemmaLang && + userL1 == other.userL1; + + @override + int get hashCode => + lemma.hashCode ^ + partOfSpeech.hashCode ^ + lemmaLang.hashCode ^ + userL1.hashCode; +} + +class LemmaDefinitionResponse { + final List emoji; + final String definition; + + LemmaDefinitionResponse({ + required this.emoji, + required this.definition, + }); + + factory LemmaDefinitionResponse.fromJson(Map json) { + return LemmaDefinitionResponse( + emoji: (json['emoji'] as List).map((e) => e as String).toList(), + definition: json['definition'] as String, + ); + } + + Map toJson() { + return { + 'emoji': emoji, + 'definition': definition, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LemmaDefinitionResponse && + runtimeType == other.runtimeType && + emoji.length == other.emoji.length && + emoji.every((element) => other.emoji.contains(element)) && + definition == other.definition; + + @override + int get hashCode => + emoji.fold(0, (prev, element) => prev ^ element.hashCode) ^ + definition.hashCode; +} + +class LemmaDictionaryRepo { + // In-memory cache with timestamps + static final Map _cache = {}; + static final Map _cacheTimestamps = {}; + + static const Duration _cacheDuration = Duration(days: 2); + + static Future get( + LemmaDefinitionRequest request, + ) async { + _clearExpiredEntries(); + + // Check the cache first + if (_cache.containsKey(request)) { + return _cache[request]!; + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final requestBody = request.toJson(); + final Response res = await req.post( + url: PApiUrls.lemmaDictionary, + body: requestBody, + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = LemmaDefinitionResponse.fromJson(decodedBody); + + // Store the response and timestamp in the cache + _cache[request] = response; + _cacheTimestamps[request] = DateTime.now(); + + return response; + } + + static void _clearExpiredEntries() { + final now = DateTime.now(); + final expiredKeys = _cacheTimestamps.entries + .where((entry) => now.difference(entry.value) > _cacheDuration) + .map((entry) => entry.key) + .toList(); + + for (final key in expiredKeys) { + _cache.remove(key); + _cacheTimestamps.remove(key); + } + } +} diff --git a/lib/pangea/repo/practice/emoji_activity_generator.dart b/lib/pangea/repo/practice/emoji_activity_generator.dart new file mode 100644 index 000000000..54a5ed65d --- /dev/null +++ b/lib/pangea/repo/practice/emoji_activity_generator.dart @@ -0,0 +1,36 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:flutter/foundation.dart'; + +class EmojiActivityGenerator { + Future get( + MessageActivityRequest req, + ) async { + debugger(when: kDebugMode && req.targetTokens.length != 1); + + final PangeaToken token = req.targetTokens.first; + + final List emojis = await token.getEmojiChoices(); + + // TODO - modify MultipleChoiceActivity flow to allow no correct answer + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: ActivityTypeEnum.emoji, + targetTokens: [token], + tgtConstructs: [token.vocabConstructID], + langCode: req.userL2, + content: ActivityContent( + question: "", + choices: emojis, + answers: emojis, + spanDisplayDetails: null, + ), + ), + ); + } +} diff --git a/lib/pangea/repo/practice/lemma_activity_generator.dart b/lib/pangea/repo/practice/lemma_activity_generator.dart new file mode 100644 index 000000000..39ee6e52a --- /dev/null +++ b/lib/pangea/repo/practice/lemma_activity_generator.dart @@ -0,0 +1,37 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; + +class LemmaActivityGenerator { + Future get( + MessageActivityRequest req, + ) async { + debugger(when: kDebugMode && req.targetTokens.length != 1); + + final token = req.targetTokens.first; + final List choices = await MatrixState + .pangeaController.getAnalytics.constructListModel + .lemmaActivityDistractors(token); + + // TODO - modify MultipleChoiceActivity flow to allow no correct answer + return MessageActivityResponse( + activity: PracticeActivityModel( + activityType: ActivityTypeEnum.lemmaId, + targetTokens: [token], + tgtConstructs: [token.vocabConstructID], + langCode: req.userL2, + content: ActivityContent( + question: "", + choices: choices, + answers: [token.lemma.text], + spanDisplayDetails: null, + ), + ), + ); + } +} diff --git a/lib/pangea/repo/practice/morph_activity_generator.dart b/lib/pangea/repo/practice/morph_activity_generator.dart new file mode 100644 index 000000000..6703f7d28 --- /dev/null +++ b/lib/pangea/repo/practice/morph_activity_generator.dart @@ -0,0 +1,100 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; + +typedef MorphActivitySequence = Map; + +typedef POSActivitySequence = List; + +class MorphActivityGenerator { + // TODO we want to define this on the server and have the client pull it down + final Map sequence = { + "en": { + "ADJ": ["AdvType", "Aspect"], + "ADP": [], + "ADV": [], + "AUX": ["Tense", "Number"], + "CCONJ": [], + "DET": [], + "NOUN": ["Number"], + "NUM": [], + "PRON": ["Number", "Person"], + "SCONJ": [], + "PUNCT": [], + "VERB": ["Tense", "Aspect"], + }, + }; + + /// Get the sequence of activities for a given part of speech + /// The sequence is a list of morphological features that should be practiced + /// in order for the given part of speech + Future getSequence(String langCode, String pos) async { + if (!sequence.containsKey(langCode)) { + langCode = "en"; + } + final MorphActivitySequence morphActivitySequence = sequence[langCode]!; + + if (!morphActivitySequence.containsKey(pos)) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "No sequence defined", + data: {"langCode": langCode, "pos": pos}, + ); + return []; + } + + return morphActivitySequence[pos]!; + } + + /// Generate a morphological activity for a given token and morphological feature + Future get( + MessageActivityRequest req, + ) async { + debugger(when: kDebugMode && req.targetTokens.length != 1); + + debugger(when: kDebugMode && req.targetMorphFeature == null); + + final PangeaToken token = req.targetTokens.first; + + final String morphFeature = req.targetMorphFeature!; + final String? morphTag = token.getMorphTag(morphFeature); + + if (morphTag == null) { + debugger(when: kDebugMode); + throw "No morph tag found for morph feature"; + } + + final List distractors = MatrixState + .pangeaController.getAnalytics.constructListModel + .morphActivityDistractors(morphFeature, morphTag); + + return MessageActivityResponse( + activity: PracticeActivityModel( + tgtConstructs: [ + ConstructIdentifier( + lemma: morphTag, + type: ConstructTypeEnum.morph, + category: morphFeature, + ), + ], + targetTokens: req.targetTokens, + langCode: req.userL2, + activityType: ActivityTypeEnum.morphId, + content: ActivityContent( + question: "", + choices: distractors + [morphTag], + answers: [morphTag], + spanDisplayDetails: null, + ), + ), + ); + } +} diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/repo/practice/practice_repo.dart similarity index 63% rename from lib/pangea/controllers/practice_activity_generation_controller.dart rename to lib/pangea/repo/practice/practice_repo.dart index 69504d9d2..ffe2e2d52 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/repo/practice/practice_repo.dart @@ -5,6 +5,7 @@ import 'dart:developer'; import 'package:fluffychat/pangea/config/environment.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; @@ -12,14 +13,19 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/message_activi import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/network/requests.dart'; import 'package:fluffychat/pangea/network/urls.dart'; +import 'package:fluffychat/pangea/repo/practice/emoji_activity_generator.dart'; +import 'package:fluffychat/pangea/repo/practice/lemma_activity_generator.dart'; +import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:matrix/matrix.dart'; /// Represents an item in the completion cache. class _RequestCacheItem { - MessageActivityRequest req; - PracticeActivityModelResponse? practiceActivity; + final MessageActivityRequest req; + final PracticeActivityModelResponse practiceActivity; + final DateTime createdAt = DateTime.now(); _RequestCacheItem({ required this.req, @@ -34,18 +40,29 @@ class PracticeGenerationController { late PangeaController _pangeaController; - PracticeGenerationController(PangeaController pangeaController) { - _pangeaController = pangeaController; + final MorphActivityGenerator _morph = MorphActivityGenerator(); + final EmojiActivityGenerator _emoji = EmojiActivityGenerator(); + final LemmaActivityGenerator _lemma = LemmaActivityGenerator(); + + PracticeGenerationController() { + _pangeaController = MatrixState.pangeaController; _initializeCacheClearing(); } void _initializeCacheClearing() { - const duration = Duration(minutes: 2); + const duration = Duration(minutes: 10); _cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache()); } void _clearCache() { - _cache.clear(); + 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() { @@ -72,7 +89,7 @@ class PracticeGenerationController { ); } - Future _fetch({ + Future _fetchFromServer({ required String accessToken, required MessageActivityRequest requestModel, }) async { @@ -97,9 +114,31 @@ class PracticeGenerationController { } } - //TODO - allow return of activity content before sending the event - // this requires some downstream changes to the way the event is handled - Future getPracticeActivity( + Future _routePracticeActivity({ + required String accessToken, + required MessageActivityRequest req, + }) 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); + case ActivityTypeEnum.lemmaId: + return _lemma.get(req); + case ActivityTypeEnum.morphId: + return _morph.get(req); + case ActivityTypeEnum.wordFocusListening: + // TODO bring clientside because more efficient + case ActivityTypeEnum.wordMeaning: + // TODO get correct answer with translation and distractors with distractor service + case ActivityTypeEnum.hiddenWordListening: + return _fetchFromServer( + accessToken: accessToken, + requestModel: req, + ); + } + } + + Future getPracticeActivity( MessageActivityRequest req, PangeaMessageEvent event, ) async { @@ -111,11 +150,13 @@ class PracticeGenerationController { return _cache[cacheKey]!.practiceActivity; } - final MessageActivityResponse res = await _fetch( + final MessageActivityResponse res = await _routePracticeActivity( accessToken: _pangeaController.userController.accessToken, - requestModel: req, + req: req, ); + // TODO resolve some wierdness here whereby the activity can be null but then... it's not + final eventCompleter = Completer(); debugPrint('Activity generated: ${res.activity.toJson()}'); diff --git a/lib/pangea/utils/grammar/get_grammar_copy.dart b/lib/pangea/utils/grammar/get_grammar_copy.dart index d0470ca97..9548158d1 100644 --- a/lib/pangea/utils/grammar/get_grammar_copy.dart +++ b/lib/pangea/utils/grammar/get_grammar_copy.dart @@ -1,9 +1,6 @@ // ignore_for_file: constant_identifier_names -import 'dart:developer'; - import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -454,7 +451,7 @@ String? getGrammarCopy({ ); return L10n.of(context).grammarCopyUnknown; default: - debugger(when: kDebugMode); + // debugger(when: kDebugMode); ErrorHandler.logError( e: 'Need to add copy to intl_en.arb', data: { diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 9cdf41cfb..939d8bbe7 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -6,7 +6,7 @@ import 'package:fluffychat/pages/chat/events/audio_player.dart'; import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; @@ -185,7 +185,6 @@ class MessageAudioCardState extends State { mainAxisSize: MainAxisSize.min, children: [ Container( - padding: const EdgeInsets.all(8), alignment: Alignment.center, child: _isLoading ? const ToolbarContentLoadingIndicator() @@ -199,6 +198,7 @@ class MessageAudioCardState extends State { setIsPlayingAudio: widget.setIsPlayingAudio, fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, + padding: 0, ) : const CardErrorWidget( error: "Null audio file in message_audio_card", diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index aab0e298c..0ba5b96fa 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -1,17 +1,17 @@ import 'dart:async'; -import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; -import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart'; @@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:matrix/matrix.dart'; @@ -58,7 +57,7 @@ class MessageOverlayController extends State StreamSubscription? _reactionSubscription; Animation? _overlayPositionAnimation; - MessageMode toolbarMode = MessageMode.translation; + MessageMode toolbarMode = MessageMode.noneSelected; PangeaTokenText? _selectedSpan; List? tokens; @@ -74,33 +73,30 @@ class MessageOverlayController extends State int get activitiesLeftToComplete => messageAnalyticsEntry?.numActivities ?? 0; - bool get isPracticeComplete => activitiesLeftToComplete <= 0; + bool get isPracticeComplete => + activitiesLeftToComplete <= 0 || !messageInUserL2; /// Decides whether an _initialSelectedToken should be used /// for a first practice activity on the word meaning - PangeaToken? get _selectedTargetTokenForWordMeaning { + void _initializeSelectedToken() { // if there is no initial selected token, then we don't need to do anything if (widget._initialSelectedToken == null || messageAnalyticsEntry == null) { - return null; + return; } - debugPrint( - "selected token ${widget._initialSelectedToken?.analyticsDebugPrint}", - ); - debugPrint( - "${widget._initialSelectedToken?.vocabConstruct.uses.map((u) => "${u.useType} ${u.timeStamp}").join(", ")}", - ); - // 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 - // final shouldDoActivity = widget._initialSelectedToken! - // .shouldDoActivity(ActivityTypeEnum.wordMeaning); - return !isInHiddenWordActivity ? widget._initialSelectedToken : null; + // whether the activity should generally be involved in an activity + final selected = + !isInHiddenWordActivity ? widget._initialSelectedToken : null; + + if (selected != null) { + _updateSelectedSpan(selected.text); + } } @override @@ -111,6 +107,23 @@ class MessageOverlayController extends State _setupSubscriptions(); } + void _updateSelectedSpan(PangeaTokenText selectedSpan) { + _selectedSpan = selectedSpan; + + widget.chatController.choreographer.tts.tryToSpeak( + selectedSpan.content, + context, + widget._pangeaMessageEvent?.eventId, + ); + + // if a token is selected, then the toolbar should be in wordZoom mode + if (toolbarMode != MessageMode.wordZoom) { + debugPrint("_updateSelectedSpan: setting toolbarMode to wordZoom"); + updateToolbarMode(MessageMode.wordZoom); + } + setState(() {}); + } + void _setupSubscriptions() { _animationController = AnimationController( vsync: this, @@ -165,11 +178,7 @@ class MessageOverlayController extends State }, ); } finally { - if (_selectedTargetTokenForWordMeaning != null) { - messageAnalyticsEntry?.addForWordMeaning( - _selectedTargetTokenForWordMeaning!, - ); - } + _initializeSelectedToken(); _setInitialToolbarMode(); initialized = true; if (mounted) setState(() {}); @@ -186,22 +195,24 @@ class MessageOverlayController extends State return setState(() {}); } - // 1) we're only going to do activities if we have tokens for the message - // 2) if the user selects a span on initialization, then we want to give - // them a practice activity on that word - // 3) if the user has activities left to complete, then we want to give them - if (tokens != null && activitiesLeftToComplete > 0 && messageInUserL2) { + // 1) if we have a hidden word activity, then we should start with that + if (messageAnalyticsEntry?.nextActivity?.activityType == + ActivityTypeEnum.hiddenWordListening) { return setState(() => toolbarMode = MessageMode.practiceActivity); } + if (selectedToken != null) { + return setState(() => toolbarMode = MessageMode.wordZoom); + } + // Note: this setting is now hidden so this will always be false // leaving this here in case we want to bring it back - if (MatrixState.pangeaController.userController.profile.userSettings - .autoPlayMessages) { - return setState(() => toolbarMode = MessageMode.textToSpeech); - } + // if (MatrixState.pangeaController.userController.profile.userSettings + // .autoPlayMessages) { + // return setState(() => toolbarMode = MessageMode.textToSpeech); + // } - setState(() => toolbarMode = MessageMode.translation); + // defaults to noneSelected } /// We need to check if the setState call is safe to call immediately @@ -242,30 +253,30 @@ 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() { + messageAnalyticsEntry!.onActivityComplete(); if (!mounted) return; - _clearSelection(); setState(() {}); } /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { - messageAnalyticsEntry?.clearActivityQueue(); - _clearSelection(); + messageAnalyticsEntry?.exitPracticeFlow(); setState(() {}); } void updateToolbarMode(MessageMode mode) { setState(() { + // only practiceActivity and wordZoom make sense with selectedSpan + if (![MessageMode.practiceActivity, MessageMode.wordZoom] + .contains(mode)) { + debugPrint("updateToolbarMode: $mode - clearing selectedSpan"); + _selectedSpan = null; + } toolbarMode = mode; }); } - void _clearSelection() { - _selectedSpan = null; - setState(() {}); - } - /// The text that the toolbar should target /// If there is no selectedSpan, then the whole message is the target /// If there is a selectedSpan, then the target is the selected text @@ -284,69 +295,42 @@ class MessageOverlayController extends State void onClickOverlayMessageToken( PangeaToken token, ) { - if ([ - MessageMode.practiceActivity, - // MessageMode.textToSpeech - ].contains(toolbarMode) || - isPlayingAudio) { + if (toolbarMode == MessageMode.practiceActivity && + messageAnalyticsEntry?.nextActivity?.activityType == + ActivityTypeEnum.hiddenWordListening) { return; } // if there's no selected span, then select the token - if (_selectedSpan == null) { - _selectedSpan = token.text; - } else { - // if there is a selected span, then deselect the token if it's the same - if (isTokenSelected(token)) { - _selectedSpan = null; - } else { - // if there is a selected span but it is not the same, then select the token - _selectedSpan = token.text; - } - } - - if (_selectedSpan != null) { - widget.chatController.choreographer.tts.tryToSpeak( - token.text.content, - context, - pangeaMessageEvent!.eventId, - ); - } - - setState(() {}); - } - - void setSelectedSpan(PracticeActivityModel activity) { - if (pangeaMessageEvent == null) return; - - final RelevantSpanDisplayDetails? span = - activity.content.spanDisplayDetails; - - if (span == null) { - debugger(when: kDebugMode); - return; - } - - if (span.displayInstructions != ActivityDisplayInstructionsEnum.nothing) { - _selectedSpan = PangeaTokenText( - offset: span.offset, - length: span.length, - content: widget._pangeaMessageEvent!.messageDisplayText - .substring(span.offset, span.offset + span.length), - ); - } else { - _selectedSpan = null; - } + // PangeaTokenText? newSelectedSpan; + // if (_selectedSpan == null) { + // newSelectedSpan = token.text; + // } else { + // // if there is a selected span, then deselect the token if it's the same + // if (isTokenSelected(token)) { + // newSelectedSpan = null; + // } else { + // // if there is a selected span but it is not the same, then select the token + // newSelectedSpan = token.text; + // } + // } + // if (newSelectedSpan != null) { + // updateToolbarMode(MessageMode.practiceActivity); + // } + _updateSelectedSpan(token.text); setState(() {}); } /// Whether the given token is currently selected bool isTokenSelected(PangeaToken token) { - return _selectedSpan?.offset == token.text.offset && + final isSelected = _selectedSpan?.offset == token.text.offset && _selectedSpan?.length == token.text.length; + return isSelected; } + PangeaToken? get selectedToken => tokens?.firstWhereOrNull(isTokenSelected); + /// Whether the overlay is currently displaying a selection bool get isSelection => _selectedSpan != null; @@ -394,7 +378,7 @@ class MessageOverlayController extends State _belowMessageHeight; final bool hasHeaderOverflow = - _messageOffset!.dy < (AppConfig.toolbarMaxHeight + _headerHeight); + _messageOffset!.dy < (AppConfig.toolbarMaxHeight + _headerHeight + 10); final bool hasFooterOverflow = (_footerHeight + 5) > currentBottomOffset; if (!hasHeaderOverflow && !hasFooterOverflow) return; @@ -418,7 +402,7 @@ class MessageOverlayController extends State final remainingSpace = _screenHeight! - totalTopOffset; if (remainingSpace < _headerHeight) { // the overlay could run over the header, so it needs to be shifted down - animationEndOffset -= (_headerHeight - remainingSpace); + animationEndOffset -= (_headerHeight - remainingSpace + 10); } scrollOffset = animationEndOffset - currentBottomOffset; } else if (hasFooterOverflow) { @@ -591,7 +575,7 @@ class MessageOverlayController extends State if (pangeaMessageEvent != null) MessageToolbar( pangeaMessageEvent: pangeaMessageEvent!, - overLayController: this, + overlayController: this, ), const SizedBox(height: 8), SizedBox( @@ -624,7 +608,6 @@ class MessageOverlayController extends State ToolbarButtons( event: widget._event, overlayController: this, - width: 250, ), ], ), @@ -695,7 +678,10 @@ class MessageOverlayController extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - OverlayFooter(controller: widget.chatController), + OverlayFooter( + controller: widget.chatController, + overlayController: this, + ), SizedBox(height: _mediaQuery?.padding.bottom ?? 0), ], ), diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 65dc93031..84d90a5e6 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -1,10 +1,7 @@ -import 'dart:developer'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart'; @@ -12,143 +9,91 @@ import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; -import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; -import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; -import 'package:fluffychat/pangea/widgets/message_display_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix_api_lite/model/message_types.dart'; const double minCardHeight = 70; class MessageToolbar extends StatelessWidget { final PangeaMessageEvent pangeaMessageEvent; - final MessageOverlayController overLayController; + final MessageOverlayController overlayController; const MessageToolbar({ super.key, required this.pangeaMessageEvent, - required this.overLayController, + required this.overlayController, }); TtsController get ttsController => - overLayController.widget.chatController.choreographer.tts; + overlayController.widget.chatController.choreographer.tts; - Widget toolbarContent(BuildContext context) { + Widget? toolbarContent(BuildContext context) { final bool subscribed = MatrixState.pangeaController.subscriptionController.isSubscribed; if (!subscribed) { return MessageUnsubscribedCard( - controller: overLayController, + controller: overlayController, ); } - if (!overLayController.initialized) { + if (overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ?? + false) { + return PracticeActivityCard( + pangeaMessageEvent: pangeaMessageEvent, + overlayController: overlayController, + targetTokensAndActivityType: + overlayController.messageAnalyticsEntry!.nextActivity!, + ); + } + + if (!overlayController.initialized) { return const ToolbarContentLoadingIndicator(); } - switch (overLayController.toolbarMode) { + switch (overlayController.toolbarMode) { case MessageMode.translation: return MessageTranslationCard( messageEvent: pangeaMessageEvent, - selection: overLayController.selectedSpan, + selection: overlayController.selectedSpan, ); case MessageMode.textToSpeech: return MessageAudioCard( messageEvent: pangeaMessageEvent, - overlayController: overLayController, - selection: overLayController.selectedSpan, + overlayController: overlayController, + selection: overlayController.selectedSpan, tts: ttsController, - setIsPlayingAudio: overLayController.setIsPlayingAudio, + setIsPlayingAudio: overlayController.setIsPlayingAudio, ); case MessageMode.speechToText: return MessageSpeechToTextCard( messageEvent: pangeaMessageEvent, ); - case MessageMode.definition: - if (!overLayController.isSelection) { - return FutureBuilder( - //TODO - convert this to synchronous if possible - future: Future.value( - pangeaMessageEvent.messageDisplayRepresentation?.tokens, - ), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const ToolbarContentLoadingIndicator(); - } else if (snapshot.hasError || - snapshot.data == null || - snapshot.data!.isEmpty) { - return const Padding( - padding: EdgeInsets.all(8), - child: CardErrorWidget( - error: "No tokens available", - maxWidth: AppConfig.toolbarMinWidth, - ), - ); - } else { - return MessageDisplayCard( - displayText: L10n.of(context).selectToDefine, - ); - } - }, - ); - } else { - try { - final selectedText = overLayController.targetText; - - return WordDataCard( - word: selectedText, - wordLang: pangeaMessageEvent.messageDisplayLangCode, - fullText: pangeaMessageEvent.messageDisplayText, - fullTextLang: pangeaMessageEvent.messageDisplayLangCode, - hasInfo: true, - room: overLayController.widget.chatController.room, - ); - } catch (e, s) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: "Error in WordDataCard", - s: s, - data: { - "word": overLayController.targetText, - "fullText": pangeaMessageEvent.messageDisplayText, - }, - ); - return const SizedBox(); - } - } - case MessageMode.practiceActivity: - // If not in the target language show specific messsage - if (!overLayController.messageInUserL2) { - return MessageDisplayCard( - displayText: L10n.of(context) - .messageNotInTargetLang, // Pass the display text, - ); - } - return PracticeActivityCard( - pangeaMessageEvent: pangeaMessageEvent, - overlayController: overLayController, - ); - default: - debugger(when: kDebugMode); - ErrorHandler.logError( - e: "Invalid toolbar mode", - s: StackTrace.current, - data: {"newMode": overLayController.toolbarMode}, - ); + case MessageMode.noneSelected: return const SizedBox(); + case MessageMode.practiceActivity: + case MessageMode.wordZoom: + if (overlayController.selectedToken == null) { + return const SizedBox(); + } + return WordZoomWidget( + token: overlayController.selectedToken!, + messageEvent: overlayController.pangeaMessageEvent!, + tts: ttsController, + overlayController: overlayController, + ); } } @override Widget build(BuildContext context) { - if (![MessageTypes.Text, MessageTypes.Audio].contains( - pangeaMessageEvent.event.messageType, - )) { + if (overlayController.toolbarMode == MessageMode.noneSelected || + ![MessageTypes.Text, MessageTypes.Audio].contains( + pangeaMessageEvent.event.messageType, + )) { return const SizedBox(); } diff --git a/lib/pangea/widgets/chat/message_toolbar_buttons.dart b/lib/pangea/widgets/chat/message_toolbar_buttons.dart index 71a20189f..26ea040fc 100644 --- a/lib/pangea/widgets/chat/message_toolbar_buttons.dart +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -4,10 +4,8 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/pressable_button.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,36 +14,27 @@ import 'package:matrix/matrix.dart'; class ToolbarButtons extends StatelessWidget { final Event event; final MessageOverlayController overlayController; - final double width; const ToolbarButtons({ required this.event, required this.overlayController, - required this.width, super.key, }); - PangeaMessageEvent? get pangeaMessageEvent => - overlayController.pangeaMessageEvent; + int? get activitiesCompleted => + overlayController.pangeaMessageEvent?.numberOfActivitiesCompleted; List get modes => MessageMode.values .where((mode) => mode.shouldShowAsToolbarButton(event)) .toList(); - bool get messageInUserL2 => - pangeaMessageEvent?.messageDisplayLangCode == - MatrixState.pangeaController.languageController.userL2?.langCode; - static const double iconWidth = 36.0; - static const buttonSize = 40.0; + static const double buttonSize = 40.0; + static const double width = 250.0; @override Widget build(BuildContext context) { - final totallyDone = - overlayController.isPracticeComplete || !messageInUserL2; - final double barWidth = width - iconWidth; - - if (!overlayController.showToolbarButtons || pangeaMessageEvent == null) { + if (!overlayController.showToolbarButtons) { return const SizedBox(); } @@ -62,6 +51,7 @@ class ToolbarButtons extends StatelessWidget { height: 12, decoration: BoxDecoration( color: MessageModeExtension.barAndLockedButtonColor(context), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), ), margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), ), @@ -69,13 +59,12 @@ class ToolbarButtons extends StatelessWidget { duration: FluffyThemes.animationDuration, height: 12, width: overlayController.isPracticeComplete - ? barWidth - : min( - barWidth, - (barWidth / 3) * - pangeaMessageEvent!.numberOfActivitiesCompleted, - ), - color: AppConfig.success, + ? width + : min(width, (width / 2) * activitiesCompleted!), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + color: AppConfig.success, + ), margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), ), ], @@ -86,52 +75,25 @@ class ToolbarButtons extends StatelessWidget { children: modes.mapIndexed((index, mode) { final enabled = mode.isUnlocked( index, - pangeaMessageEvent!.numberOfActivitiesCompleted, - totallyDone, + activitiesCompleted!, + overlayController.isPracticeComplete, ); final color = mode.iconButtonColor( context, index, overlayController.toolbarMode, - pangeaMessageEvent!.numberOfActivitiesCompleted, - totallyDone, + activitiesCompleted!, + overlayController.isPracticeComplete, ); - return Tooltip( - message: mode.tooltip(context), - child: Stack( - alignment: Alignment.center, - children: [ - PressableButton( - borderRadius: BorderRadius.circular(20), - depressed: - !enabled || mode == overlayController.toolbarMode, + return mode.showButton + ? ToolbarButton( + mode: mode, + overlayController: overlayController, + enabled: enabled, + buttonSize: buttonSize, color: color, - onPressed: enabled - ? () => overlayController.updateToolbarMode(mode) - : null, - clickPlayer: overlayController - .widget.chatController.choreographer.clickPlayer, - child: AnimatedContainer( - duration: FluffyThemes.animationDuration, - height: buttonSize, - width: buttonSize, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - child: Icon( - mode.icon, - size: 20, - color: mode == overlayController.toolbarMode - ? Colors.white - : null, - ), - ), - ), - if (!enabled) const DisabledAnimation(), - ], - ), - ); + ) + : const SizedBox(width: buttonSize); }).toList(), ), ], @@ -216,3 +178,56 @@ class DisabledAnimationState extends State ); } } + +class ToolbarButton extends StatelessWidget { + final MessageMode mode; + final MessageOverlayController overlayController; + final bool enabled; + final double buttonSize; + final Color color; + + const ToolbarButton({ + required this.mode, + required this.overlayController, + required this.enabled, + required this.buttonSize, + required this.color, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: mode.tooltip(context), + child: Stack( + alignment: Alignment.center, + children: [ + PressableButton( + borderRadius: BorderRadius.circular(20), + depressed: !enabled || mode == overlayController.toolbarMode, + color: color, + onPressed: enabled + ? () => overlayController.updateToolbarMode(mode) + : null, + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: buttonSize, + width: buttonSize, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + child: Icon( + mode.icon, + size: 20, + color: + mode == overlayController.toolbarMode ? Colors.white : null, + ), + ), + ), + if (!enabled) const DisabledAnimation(), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index 8c575aa2b..49ba2a9f4 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -1,7 +1,7 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; diff --git a/lib/pangea/widgets/chat/overlay_footer.dart b/lib/pangea/widgets/chat/overlay_footer.dart index 98d8d696f..b0efa0450 100644 --- a/lib/pangea/widgets/chat/overlay_footer.dart +++ b/lib/pangea/widgets/chat/overlay_footer.dart @@ -2,13 +2,16 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/chat_input_row.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:flutter/material.dart'; class OverlayFooter extends StatelessWidget { final ChatController controller; + final MessageOverlayController overlayController; const OverlayFooter({ required this.controller, + required this.overlayController, super.key, }); @@ -34,7 +37,8 @@ class OverlayFooter extends StatelessWidget { borderRadius: const BorderRadius.all( Radius.circular(AppConfig.borderRadius), ), - child: ChatInputRow(controller, isOverlay: true), + child: + ChatInputRow(controller, overlayController: overlayController), ), ], ), diff --git a/lib/pangea/widgets/chat/overlay_message_text.dart b/lib/pangea/widgets/chat/overlay_message_text.dart deleted file mode 100644 index 088ce7c49..000000000 --- a/lib/pangea/widgets/chat/overlay_message_text.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class OverlayMessageText extends StatefulWidget { - final PangeaMessageEvent pangeaMessageEvent; - final MessageOverlayController overlayController; - - const OverlayMessageText({ - super.key, - required this.pangeaMessageEvent, - required this.overlayController, - }); - - @override - OverlayMessageTextState createState() => OverlayMessageTextState(); -} - -class OverlayMessageTextState extends State { - List? _tokens; - - @override - void initState() { - super.initState(); - _setTokens(); - } - - Future _setTokens() async { - final repEvent = widget.pangeaMessageEvent.messageDisplayRepresentation; - if (repEvent != null) { - _tokens = repEvent.tokens; - _tokens ??= await repEvent.tokensGlobal( - widget.pangeaMessageEvent.senderId, - widget.pangeaMessageEvent.originServerTs, - ); - if (mounted) setState(() {}); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final ownMessage = widget.pangeaMessageEvent.event.senderId == - Matrix.of(context).client.userID; - - final style = TextStyle( - color: ownMessage - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - height: 1.3, - fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, - ); - - if (_tokens == null || _tokens!.isEmpty) { - return Text( - widget.pangeaMessageEvent.event.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)), - hideReply: true, - ), - style: style, - ); - } - - // Convert the entire message into a list of characters - final Characters messageCharacters = - widget.pangeaMessageEvent.messageDisplayText.characters; - - // When building token positions, use grapheme cluster indices - // We use grapheme cluster indices to avoid splitting emojis and other - // complex characters that requires multiple code units. - // For instance, the emoji 🇺🇸 is represented by two code units: - // - \u{1F1FA} - // - \u{1F1F8} - final List tokenPositions = []; - int globalIndex = 0; - - for (int i = 0; i < _tokens!.length; i++) { - final token = _tokens![i]; - final start = token.start; - final end = token.end; - - // Calculate the number of grapheme clusters up to the start and end positions - final int startIndex = messageCharacters.take(start).length; - final int endIndex = messageCharacters.take(end).length; - - if (globalIndex < startIndex) { - tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex)); - } - - tokenPositions.add( - TokenPosition( - start: startIndex, - end: endIndex, - tokenIndex: i, - token: token, - ), - ); - globalIndex = endIndex; - } - - // debug prints for fixing words sticking together - // void printEscapedString(String input) { - // // Escaped string using Unicode escape sequences - // final String escapedString = input.replaceAllMapped( - // RegExp(r'[^\w\s]', unicode: true), - // (match) { - // final codeUnits = match.group(0)!.runes; - // String unicodeEscapes = ''; - // for (final rune in codeUnits) { - // unicodeEscapes += '\\u{${rune.toRadixString(16)}}'; - // } - // return unicodeEscapes; - // }, - // ); - // print("Escaped String: $escapedString"); - - // // Printing each character with its index - // int index = 0; - // for (final char in input.characters) { - // print("Index $index: $char"); - // index++; - // } - // } - - //TODO - take out of build function of every message - return RichText( - text: TextSpan( - children: tokenPositions.map((tokenPosition) { - final substring = messageCharacters - .skip(tokenPosition.start) - .take(tokenPosition.end - tokenPosition.start) - .toString(); - - if (tokenPosition.token != null) { - final isSelected = - widget.overlayController.isTokenSelected(tokenPosition.token!); - return TextSpan( - recognizer: TapGestureRecognizer() - ..onTap = () { - debugPrint( - 'tokenPosition.tokenIndex: ${tokenPosition.tokenIndex}', - ); - widget.overlayController.onClickOverlayMessageToken( - tokenPosition.token!, - ); - if (mounted) setState(() {}); - }, - text: substring, - style: style.merge( - TextStyle( - backgroundColor: isSelected - ? Theme.of(context).brightness == Brightness.light - ? Colors.black.withOpacity(0.4) - : Colors.white.withOpacity(0.4) - : Colors.transparent, - ), - ), - ); - } else { - return TextSpan( - text: substring, - style: style, - ); - } - }).toList(), - ), - ); - } -} - -class TokenPosition { - final int start; - final int end; - final PangeaToken? token; - final int tokenIndex; - - const TokenPosition({ - required this.start, - required this.end, - this.token, - this.tokenIndex = -1, - }); -} diff --git a/lib/pangea/widgets/chat/pangea_reaction_picker.dart b/lib/pangea/widgets/chat/pangea_reaction_picker.dart index 33f1a490f..4da5f6ace 100644 --- a/lib/pangea/widgets/chat/pangea_reaction_picker.dart +++ b/lib/pangea/widgets/chat/pangea_reaction_picker.dart @@ -1,18 +1,25 @@ import 'package:fluffychat/config/app_emojis.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; class PangeaReactionsPicker extends StatelessWidget { final ChatController controller; + final MessageOverlayController? overlayController; - const PangeaReactionsPicker(this.controller, {super.key}); + const PangeaReactionsPicker( + this.controller, + this.overlayController, { + super.key, + }); + + PangeaToken? get token => overlayController?.selectedToken; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (controller.showEmojiPicker) return const SizedBox.shrink(); final display = controller.editEvent == null && controller.replyEvent == null && @@ -39,6 +46,15 @@ class PangeaReactionsPicker extends StatelessWidget { emojis.remove(event.content.tryGetMap('m.relates_to')!['key']); } catch (_) {} } + + for (final event in allReactionEvents) { + try { + emojis.remove( + event.content.tryGetMap('m.relates_to')!['key'], + ); + } catch (_) {} + } + return Flexible( child: Row( children: [ @@ -65,20 +81,6 @@ class PangeaReactionsPicker extends StatelessWidget { ), ), ), - InkWell( - borderRadius: BorderRadius.circular(8), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - width: 36, - height: 56, - decoration: BoxDecoration( - color: theme.colorScheme.onInverseSurface, - shape: BoxShape.circle, - ), - child: const Icon(Icons.add_outlined), - ), - onTap: () => controller.pickEmojiReactionAction(allReactionEvents), - ), ], ), ); diff --git a/lib/pangea/widgets/chat/tts_controller.dart b/lib/pangea/widgets/chat/tts_controller.dart index 53bcb8d1a..1677e6a1b 100644 --- a/lib/pangea/widgets/chat/tts_controller.dart +++ b/lib/pangea/widgets/chat/tts_controller.dart @@ -11,7 +11,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_tts/flutter_tts.dart' as flutter_tts; import 'package:matrix/matrix_api_lite/utils/logs.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:text_to_speech/text_to_speech.dart'; class TtsController { @@ -202,32 +201,34 @@ class TtsController { stop(); Logs().i('Speaking: $text'); - final result = await (_useAlternativeTTS - ? _alternativeTTS.speak(text) - : _tts.speak(text)) - .timeout( - const Duration(seconds: 5), - onTimeout: () { - ErrorHandler.logError( - e: "Timeout on tts.speak", - data: {"text": text}, - ); - }, + final result = await Future( + () => (_useAlternativeTTS + ? _alternativeTTS.speak(text) + : _tts.speak(text)) + .timeout( + const Duration(seconds: 5), + onTimeout: () { + ErrorHandler.logError( + e: "Timeout on tts.speak", + data: {"text": text}, + ); + }, + ), ); Logs().i('Finished speaking: $text, result: $result'); // return type is dynamic but apparent its supposed to be 1 // https://pub.dev/packages/flutter_tts - if (result != 1 && !kIsWeb) { - ErrorHandler.logError( - m: 'Unexpected result from tts.speak', - data: { - 'result': result, - 'text': text, - }, - level: SentryLevel.warning, - ); - } + // if (result != 1 && !kIsWeb) { + // ErrorHandler.logError( + // m: 'Unexpected result from tts.speak', + // data: { + // 'result': result, + // 'text': text, + // }, + // level: SentryLevel.warning, + // ); + // } } catch (e, s) { debugger(when: kDebugMode); ErrorHandler.logError( @@ -243,3 +244,7 @@ class TtsController { bool get isLanguageFullySupported => _availableLangCodes.contains(targetLanguage); } + +extension on (Future,) { + timeout(Duration duration, {required Null Function() onTimeout}) {} +} diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index 4137381c8..1353458f4 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -196,56 +196,59 @@ class WordDataCardView extends StatelessWidget { maxWidth: AppConfig.toolbarMinWidth, maxHeight: AppConfig.toolbarMaxHeight, ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - children: [ - if (controller.widget.choiceFeedback != null) - Text( - controller.widget.choiceFeedback!, - style: BotStyle.text(context), - ), - const SizedBox(height: 5.0), - if (controller.wordData != null && - controller.wordNetError == null && - controller.activeL1 != null && - controller.activeL2 != null) - WordNetInfo( - wordData: controller.wordData!, - activeL1: controller.activeL1!, - activeL2: controller.activeL2!, - ), - if (controller.isLoadingWordNet) - const ToolbarContentLoadingIndicator(), - const SizedBox(height: 5.0), - // if (controller.widget.hasInfo && - // !controller.isLoadingContextualDefinition && - // controller.contextualDefinitionRes == null) - // Material( - // type: MaterialType.transparency, - // child: ListTile( - // leading: const BotFace( - // width: 40, expression: BotExpression.surprised), - // title: Text(L10n.of(context).askPangeaBot), - // onTap: controller.handleGetDefinitionButtonPress, - // ), - // ), - if (controller.isLoadingContextualDefinition) - const ToolbarContentLoadingIndicator(), - if (controller.contextualDefinitionRes != null) - Text( - controller.contextualDefinitionRes!.text, - style: BotStyle.text(context), - textAlign: TextAlign.center, - ), - if (controller.definitionError != null) - Text( - L10n.of(context).sorryNoResults, - style: BotStyle.text(context), - textAlign: TextAlign.center, - ), - ], + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + if (controller.widget.choiceFeedback != null) + Text( + controller.widget.choiceFeedback!, + style: BotStyle.text(context), + ), + const SizedBox(height: 5.0), + if (controller.wordData != null && + controller.wordNetError == null && + controller.activeL1 != null && + controller.activeL2 != null) + WordNetInfo( + wordData: controller.wordData!, + activeL1: controller.activeL1!, + activeL2: controller.activeL2!, + ), + if (controller.isLoadingWordNet) + const ToolbarContentLoadingIndicator(), + const SizedBox(height: 5.0), + // if (controller.widget.hasInfo && + // !controller.isLoadingContextualDefinition && + // controller.contextualDefinitionRes == null) + // Material( + // type: MaterialType.transparency, + // child: ListTile( + // leading: const BotFace( + // width: 40, expression: BotExpression.surprised), + // title: Text(L10n.of(context).askPangeaBot), + // onTap: controller.handleGetDefinitionButtonPress, + // ), + // ), + if (controller.isLoadingContextualDefinition) + const ToolbarContentLoadingIndicator(), + if (controller.contextualDefinitionRes != null) + Text( + controller.contextualDefinitionRes!.text, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + if (controller.definitionError != null) + Text( + L10n.of(context).sorryNoResults, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + ], + ), ), ), ), diff --git a/lib/pangea/widgets/practice_activity/emoji_practice_button.dart b/lib/pangea/widgets/practice_activity/emoji_practice_button.dart new file mode 100644 index 000000000..19391b1ad --- /dev/null +++ b/lib/pangea/widgets/practice_activity/emoji_practice_button.dart @@ -0,0 +1,63 @@ +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:flutter/material.dart'; + +class EmojiPracticeButton extends StatefulWidget { + final PangeaToken token; + final VoidCallback onPressed; + + final String? emoji; + final Function(String) setEmoji; + + const EmojiPracticeButton({ + required this.token, + required this.onPressed, + this.emoji, + required this.setEmoji, + super.key, + }); + + @override + EmojiPracticeButtonState createState() => EmojiPracticeButtonState(); +} + +class EmojiPracticeButtonState extends State { + @override + void didUpdateWidget(covariant EmojiPracticeButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.token != oldWidget.token) { + setState(() {}); + } + } + + bool get _canDoActivity { + final canDo = widget.token.shouldDoActivity( + a: ActivityTypeEnum.emoji, + feature: null, + tag: null, + ); + return canDo; + } + + @override + Widget build(BuildContext context) { + final emoji = widget.token.getEmoji(); + return SizedBox( + height: 40, + width: 40, + child: _canDoActivity || emoji != null + ? IconButton( + onPressed: () { + widget.onPressed(); + if (widget.emoji == null && emoji != null) { + widget.setEmoji(emoji); + } + }, + icon: emoji == null + ? const Icon(Icons.add_reaction_outlined) + : Text(emoji), + ) + : const SizedBox.shrink(), + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 70287f518..42744eb9b 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -1,6 +1,5 @@ import 'dart:developer'; -import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart'; @@ -9,7 +8,9 @@ import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart'; @@ -24,12 +25,14 @@ class MultipleChoiceActivity extends StatefulWidget { final PracticeActivityModel currentActivity; final Event event; final VoidCallback? onError; + final MessageOverlayController overlayController; const MultipleChoiceActivity({ super.key, required this.practiceCardController, required this.currentActivity, required this.event, + required this.overlayController, this.onError, }); @@ -62,7 +65,7 @@ class MultipleChoiceActivityState extends State { void speakTargetTokens() { if (widget.practiceCardController.currentActivity?.shouldPlayTargetTokens ?? false) { - widget.practiceCardController.tts.tryToSpeak( + tts.tryToSpeak( PangeaToken.reconstructText( widget.practiceCardController.currentActivity!.targetTokens!, ), @@ -72,7 +75,8 @@ class MultipleChoiceActivityState extends State { } } - TtsController get tts => widget.practiceCardController.tts; + TtsController get tts => + widget.overlayController.widget.chatController.choreographer.tts; void updateChoice(String value, int index) { final bool isCorrect = @@ -145,7 +149,7 @@ class MultipleChoiceActivityState extends State { if (widget.currentActivity.content.isCorrect(value, index)) { MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first .then((_) { - widget.practiceCardController.onActivityFinish(); + widget.practiceCardController.onActivityFinish(correctAnswer: value); }); } @@ -156,6 +160,40 @@ class MultipleChoiceActivityState extends State { } } + List choices(BuildContext context) { + final activity = widget.currentActivity.content; + 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.morphFeature; + if (morphFeature == null) return value; + return getGrammarCopy( + category: morphFeature, + lemma: value, + context: context, + ) ?? + value; + } + @override Widget build(BuildContext context) { final PracticeActivityModel practiceActivity = widget.currentActivity; @@ -175,7 +213,7 @@ class MultipleChoiceActivityState extends State { if (practiceActivity.activityType == ActivityTypeEnum.wordFocusListening) WordAudioButton( - text: practiceActivity.content.answer, + text: practiceActivity.content.answers.first, ttsController: tts, eventID: widget.event.eventId, ), @@ -184,11 +222,9 @@ class MultipleChoiceActivityState extends State { MessageAudioCard( messageEvent: widget.practiceCardController.widget.pangeaMessageEvent, - overlayController: - widget.practiceCardController.widget.overlayController, + overlayController: widget.overlayController, tts: tts, - setIsPlayingAudio: widget.practiceCardController.widget - .overlayController.setIsPlayingAudio, + setIsPlayingAudio: widget.overlayController.setIsPlayingAudio, onError: widget.onError, ), ChoicesArray( @@ -197,37 +233,27 @@ class MultipleChoiceActivityState extends State { originalSpan: "placeholder", onPressed: updateChoice, selectedChoiceIndex: selectedChoiceIndex, - choices: practiceActivity.content.choices - .mapIndexed( - (index, value) => Choice( - text: value, - color: currentRecordModel?.hasTextResponse(value) ?? false - ? practiceActivity.content.choiceColor(index) - : null, - isGold: practiceActivity.content.isCorrect(value, index), - ), - ) - .toList(), + choices: choices(context), isActive: true, id: currentRecordModel?.hashCode.toString(), tts: practiceActivity.activityType.includeTTSOnClick ? tts : null, - enableAudio: !widget - .practiceCardController.widget.overlayController.isPlayingAudio, + enableAudio: !widget.overlayController.isPlayingAudio, + getDisplayCopy: _getDisplayCopy, ), ], ); - return Container( - padding: const EdgeInsets.all(20), + return ConstrainedBox( constraints: const BoxConstraints( + maxWidth: AppConfig.toolbarMinWidth, maxHeight: AppConfig.toolbarMaxHeight, - minWidth: AppConfig.toolbarMinWidth, - minHeight: AppConfig.toolbarMinHeight, ), - child: - practiceActivity.activityType == ActivityTypeEnum.hiddenWordListening - ? SingleChildScrollView(child: content) - : content, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: content, + ), + ), ); } } diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index e452e0d4f..3a7f69d79 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -2,40 +2,51 @@ import 'dart:async'; import 'dart:developer'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart'; import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/repo/practice/practice_repo.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; -import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; -import 'package:fluffychat/pangea/widgets/content_issue_button.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; /// The wrapper for practice activity content. /// Handles the activities associated with a message, /// their navigation, and the management of completion records class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; + final TargetTokensAndActivityType targetTokensAndActivityType; final MessageOverlayController overlayController; + final WordZoomWidgetState? wordDetailsController; + + final String? morphFeature; + + //TODO - modifications + // 1) Future and Future as parameters + // 2) onFinish callback as parameter + // 3) take out logic fetching activity const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, + required this.targetTokensAndActivityType, required this.overlayController, + this.morphFeature, + this.wordDetailsController, }); @override @@ -44,27 +55,37 @@ class PracticeActivityCard extends StatefulWidget { class PracticeActivityCardState extends State { PracticeActivityModel? currentActivity; - Completer? currentActivityCompleter; - - PracticeActivityRecordModel? currentCompletionRecord; bool fetchingActivity = false; - - List get practiceActivities => - widget.pangeaMessageEvent.practiceActivities; - - // Used to show an animation when the user completes an activity - // while simultaneously fetching a new activity and not showing the loading spinner - // until the appropriate time has passed to 'savor the joy' - Duration appropriateTimeForJoy = const Duration(milliseconds: 2500); bool savoringTheJoy = false; - TtsController get tts => - widget.overlayController.widget.chatController.choreographer.tts; + PracticeActivityRecordModel? currentCompletionRecord; + Completer? currentActivityCompleter; + + PracticeGenerationController practiceGenerationController = + PracticeGenerationController(); + + PangeaController get pangeaController => MatrixState.pangeaController; @override void initState() { super.initState(); - initialize(); + _fetchActivity(); + } + + @override + void didUpdateWidget(PracticeActivityCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.targetTokensAndActivityType != + widget.targetTokensAndActivityType || + oldWidget.morphFeature != widget.morphFeature) { + _fetchActivity(); + } + } + + @override + void dispose() { + practiceGenerationController.dispose(); + super.dispose(); } void _updateFetchingActivity(bool value) { @@ -72,129 +93,120 @@ class PracticeActivityCardState extends State { if (mounted) setState(() => fetchingActivity = value); } - void _setPracticeActivity(PracticeActivityModel? activity) { - //set elsewhere but just in case - fetchingActivity = false; - - currentActivity = activity; - - if (activity == null) { - widget.overlayController.exitPracticeFlow(); + Future _fetchActivity({ + ActivityQualityFeedback? activityFeedback, + }) async { + if (!mounted || + !pangeaController.languageController.languagesSet || + widget.overlayController.messageAnalyticsEntry == null) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); return; } - //make new completion record - currentCompletionRecord = PracticeActivityRecordModel( - question: activity.question, - ); - - widget.overlayController.setSelectedSpan(activity); - } - - /// Get an existing activity if there is one. - /// If not, get a new activity from the server. - Future initialize() async { - _setPracticeActivity( - await _fetchActivity(), - ); - } - - Future _fetchActivity({ - ActivityQualityFeedback? activityFeedback, - }) async { try { - debugPrint('Fetching activity'); _updateFetchingActivity(true); - - // target tokens can be empty if activities have been completed for each - // it's set on initialization and then removed when each activity is completed - if (!mounted || - !pangeaController.languageController.languagesSet || - widget.overlayController.messageAnalyticsEntry == null) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - return null; - } - - final nextActivitySpecs = - widget.overlayController.messageAnalyticsEntry?.nextActivity; - // the client is going to be choosing the next activity now - // if nothing is set then it must be done with practice - if (nextActivitySpecs == null) { - debugPrint("No next activity set, exiting practice flow"); - _updateFetchingActivity(false); - return null; - } - - // check if we already have an activity matching the specs - final existingActivity = practiceActivities.firstWhereOrNull( - (activity) => - nextActivitySpecs.matchesActivity(activity.practiceActivity), - ); - if (existingActivity != null) { - debugPrint('found existing activity'); - _updateFetchingActivity(false); - existingActivity.practiceActivity.targetTokens = - nextActivitySpecs.tokens; - currentActivityCompleter = Completer(); - currentActivityCompleter!.complete(existingActivity); - return existingActivity.practiceActivity; - } - - debugPrint( - "client requesting ${nextActivitySpecs.activityType.string} for: ${nextActivitySpecs.tokens.map((t) => "construct: ${t.lemma.text}:${t.pos} points: ${t.vocabConstruct.points}").join(' ')}", + final activity = await _fetchActivityModel( + activityFeedback: activityFeedback, ); - // debugger( - // when: kDebugMode && - // nextActivitySpecs.tokens - // .map((a) => a.vocabConstruct.points) - // .reduce((a, b) => a + b) > - // 30 && - // nextActivitySpecs.activityType == ActivityTypeEnum.wordMeaning, - // ); - - final PracticeActivityModelResponse? activityResponse = - await pangeaController.practiceGenerationController - .getPracticeActivity( - MessageActivityRequest( - userL1: pangeaController.languageController.userL1!.langCode, - userL2: pangeaController.languageController.userL2!.langCode, - messageText: widget.pangeaMessageEvent.messageDisplayText, - messageTokens: widget.overlayController.tokens!, - activityQualityFeedback: activityFeedback, - targetTokens: nextActivitySpecs.tokens, - targetType: nextActivitySpecs.activityType, - ), - widget.pangeaMessageEvent, - ); - - currentActivityCompleter = activityResponse?.eventCompleter; - _updateFetchingActivity(false); - - if (activityResponse == null || activityResponse.activity == null) { - debugPrint('No activity found'); - return null; + currentActivity = activity; + if (activity == null) { + widget.overlayController.exitPracticeFlow(); + return; } - activityResponse.activity!.targetTokens = nextActivitySpecs.tokens; - - return activityResponse.activity; + currentCompletionRecord = PracticeActivityRecordModel( + question: activity.question, + ); } catch (e, s) { - debugger(when: kDebugMode); ErrorHandler.logError( e: e, s: s, - m: 'Failed to get new activity', data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, + 'activity': currentActivity?.toJson(), + 'record': currentCompletionRecord?.toJson(), + 'targetTokens': widget.targetTokensAndActivityType.tokens + .map((token) => token.toJson()) + .toList(), + 'activityType': widget.targetTokensAndActivityType.activityType, + 'morphFeature': widget.morphFeature, }, ); - return null; + debugger(when: kDebugMode); + } finally { + _updateFetchingActivity(false); } } + Future _fetchActivityModel({ + ActivityQualityFeedback? activityFeedback, + }) async { + debugPrint( + "fetching activity model of type: ${widget.targetTokensAndActivityType.activityType}", + ); + // check if we already have an activity matching the specs + final tokens = widget.targetTokensAndActivityType.tokens; + final type = widget.targetTokensAndActivityType.activityType; + + final existingActivity = + widget.pangeaMessageEvent.practiceActivities.firstWhereOrNull( + (activity) { + final sameActivity = activity.practiceActivity.targetTokens != null && + activity.practiceActivity.activityType == type && + activity.practiceActivity.targetTokens! + .map((t) => t.vocabConstructID.string) + .toSet() + .containsAll( + tokens.map((t) => t.vocabConstructID.string).toSet(), + ); + if (type != ActivityTypeEnum.morphId || sameActivity == false) { + return sameActivity; + } + + return widget.morphFeature == + activity.practiceActivity.tgtConstructs + .firstWhereOrNull( + (c) => c.type == ConstructTypeEnum.morph, + ) + ?.category; + }, + ); + + if (existingActivity != null && + existingActivity.practiceActivity.content.answers.isNotEmpty && + !(existingActivity.practiceActivity.content.answers.length == 1 && + existingActivity.practiceActivity.content.answers.first.isEmpty)) { + 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: widget.pangeaMessageEvent.messageDisplayText, + messageTokens: widget.overlayController.tokens!, + activityQualityFeedback: activityFeedback, + targetTokens: tokens, + targetType: type, + targetMorphFeature: widget.morphFeature, + ); + + final PracticeActivityModelResponse activityResponse = + await practiceGenerationController.getPracticeActivity( + req, + widget.pangeaMessageEvent, + ); + + if (activityResponse.activity == null) return null; + + currentActivityCompleter = activityResponse.eventCompleter; + activityResponse.activity!.targetTokens = tokens; + return activityResponse.activity; + } + ConstructUseMetaData get metadata => ConstructUseMetaData( eventId: widget.pangeaMessageEvent.eventId, roomId: widget.pangeaMessageEvent.room.id, @@ -206,9 +218,7 @@ class PracticeActivityCardState extends State { debugger(when: savoringTheJoy && kDebugMode); if (mounted) setState(() => savoringTheJoy = true); - - await Future.delayed(appropriateTimeForJoy); - + await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => savoringTheJoy = false); } catch (e, s) { debugger(when: kDebugMode); @@ -228,30 +238,23 @@ class PracticeActivityCardState extends State { /// 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 { + void onActivityFinish({String? correctAnswer}) async { try { if (currentCompletionRecord == null || currentActivity == null) { debugger(when: kDebugMode); return; } - widget.overlayController.messageAnalyticsEntry! - .onActivityComplete(currentActivity!); - widget.overlayController.onActivityFinish(); - pangeaController.activityRecordController.completeActivity( widget.pangeaMessageEvent.eventId, ); - // wait for the joy to be savored before resolving the activity - // and setting it to replace the previous activity - final Iterable result = await Future.wait([ - _savorTheJoy(), - _fetchActivity(), - ]); - - _setPracticeActivity(result.last as PracticeActivityModel?); + await _savorTheJoy(); + widget.wordDetailsController?.onActivityFinish( + activityType: currentActivity!.activityType, + correctAnswer: correctAnswer, + ); } catch (e, s) { _onError(); debugger(when: kDebugMode); @@ -268,79 +271,14 @@ class PracticeActivityCardState extends State { void _onError() { widget.overlayController.messageAnalyticsEntry?.revealAllTokens(); - _setPracticeActivity(null); + currentActivity = null; + widget.overlayController.exitPracticeFlow(); } - bool _isActivityRedaction(EventUpdate update, String activityId) { - return update.content.containsKey('type') && - update.content['type'] == 'm.room.redaction' && - update.content.containsKey('content') && - update.content['content']['redacts'] == activityId; - } - - /// clear the current activity, record, and selection - /// fetch a new activity, including the offending activity in the request - Future submitFeedback(String feedback) async { - if (currentActivity == null || currentCompletionRecord == null) { - debugger(when: kDebugMode); - return; - } - - if (currentActivityCompleter != null) { - final activityEvent = await currentActivityCompleter!.future; - if (activityEvent != null) { - await activityEvent.event.redactEvent(reason: feedback); - final eventID = activityEvent.event.eventId; - await activityEvent.event.room.client.onEvent.stream - .firstWhere( - (update) => _isActivityRedaction(update, eventID), - ) - .timeout(const Duration(milliseconds: 2500)); - } - } else { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: Exception('No completer found for current activity'), - data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, - 'feedback': feedback, - }, - ); - } - - _fetchActivity( - activityFeedback: ActivityQualityFeedback( - feedbackText: feedback, - badActivity: currentActivity!, - ), - ).then((activity) { - _setPracticeActivity(activity); - }).catchError((onError) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: onError, - m: 'Failed to get new activity', - data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, - }, - ); - - // clear the current activity and record - currentActivity = null; - currentCompletionRecord = null; - - widget.overlayController.exitPracticeFlow(); - }); - } - - PangeaController get pangeaController => MatrixState.pangeaController; - - /// 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. + // /// 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 { switch (currentActivity?.activityType) { case null: @@ -348,11 +286,15 @@ class PracticeActivityCardState extends State { case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.lemmaId: + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.morphId: return MultipleChoiceActivity( practiceCardController: this, currentActivity: currentActivity!, event: widget.pangeaMessageEvent.event, onError: _onError, + overlayController: widget.overlayController, ); } } @@ -360,7 +302,8 @@ class PracticeActivityCardState extends State { @override Widget build(BuildContext context) { if (!fetchingActivity && currentActivity == null) { - return const GamifiedTextWidget(); + debugPrint("don't think we should be here"); + debugger(when: kDebugMode); } return Stack( @@ -379,14 +322,14 @@ class PracticeActivityCardState extends State { const ToolbarContentLoadingIndicator(), ], // Flag button in the top right corner - Positioned( - top: 0, - right: 0, - child: ContentIssueButton( - isActive: currentActivity != null, - submitFeedback: submitFeedback, - ), - ), + // Positioned( + // top: 0, + // right: 0, + // child: ContentIssueButton( + // isActive: currentActivity != null, + // submitFeedback: submitFeedback, + // ), + // ), ], ); } diff --git a/lib/pangea/widgets/practice_activity/word_text_with_audio_button.dart b/lib/pangea/widgets/practice_activity/word_text_with_audio_button.dart new file mode 100644 index 000000000..fcd52dc60 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/word_text_with_audio_button.dart @@ -0,0 +1,102 @@ +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; +import 'package:flutter/material.dart'; + +class WordTextWithAudioButton extends StatefulWidget { + final String text; + final TtsController ttsController; + final String eventID; + + const WordTextWithAudioButton({ + super.key, + required this.text, + required this.ttsController, + required this.eventID, + }); + + @override + WordAudioButtonState createState() => WordAudioButtonState(); +} + +class WordAudioButtonState extends State { + bool _isPlaying = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() {}), + onExit: (event) => setState(() {}), + child: GestureDetector( + onTap: () async { + if (_isPlaying) { + await widget.ttsController.stop(); + if (mounted) { + setState(() => _isPlaying = false); + } + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + try { + await widget.ttsController.tryToSpeak( + widget.text, + context, + widget.eventID, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "text": widget.text, + "eventID": widget.eventID, + }, + ); + } finally { + if (mounted) { + setState(() => _isPlaying = false); + } + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(4), + boxShadow: _isHovering + ? [ + BoxShadow( + color: Theme.of(context).colorScheme.secondary, + blurRadius: 4, + spreadRadius: 1, + ), + ] + : [], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _isPlaying + ? Theme.of(context).colorScheme.secondary + : null, + ), + ), + const SizedBox(width: 4), + Icon( + _isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined, + size: Theme.of(context).textTheme.bodyMedium?.fontSize, + ), + ], + ), + ), + ), + ); + } + + final bool _isHovering = false; +} diff --git a/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart b/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart new file mode 100644 index 000000000..d84443f1f --- /dev/null +++ b/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart @@ -0,0 +1,87 @@ +import 'package:fluffychat/pangea/constants/language_constants.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class ContextualTranslationWidget extends StatefulWidget { + final PangeaToken token; + final String fullText; + final String langCode; + final VoidCallback onPressed; + + final String? definition; + final Function(String) setDefinition; + + const ContextualTranslationWidget({ + super.key, + required this.token, + required this.fullText, + required this.langCode, + required this.onPressed, + required this.setDefinition, + this.definition, + }); + + @override + ContextualTranslationWidgetState createState() => + ContextualTranslationWidgetState(); +} + +class ContextualTranslationWidgetState + extends State { + @override + void initState() { + super.initState(); + if (widget.definition == null) { + _fetchDefinition(); + } + } + + @override + void didUpdateWidget(covariant ContextualTranslationWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.token != widget.token && widget.definition == null) { + _fetchDefinition(); + } + } + + Future _fetchDefinition() async { + final FullTextTranslationResponseModel response = + await FullTextTranslationRepo.translate( + accessToken: MatrixState.pangeaController.userController.accessToken, + request: FullTextTranslationRequestModel( + text: widget.fullText, + tgtLang: + MatrixState.pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + userL2: + MatrixState.pangeaController.languageController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + userL1: + MatrixState.pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + offset: widget.token.text.offset, + length: widget.token.text.length, + deepL: false, + ), + ); + widget.setDefinition(response.bestTranslation); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + height: 60, + width: 60, + child: IconButton( + iconSize: 30, + onPressed: widget.onPressed, + icon: const Icon(Symbols.dictionary), + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/lemma_definition_widget.dart b/lib/pangea/widgets/word_zoom/lemma_definition_widget.dart new file mode 100644 index 000000000..8030d905f --- /dev/null +++ b/lib/pangea/widgets/word_zoom/lemma_definition_widget.dart @@ -0,0 +1,70 @@ +import 'package:fluffychat/pangea/constants/language_constants.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; + +class LemmaDefinitionWidget extends StatefulWidget { + final PangeaToken token; + final String tokenLang; + final VoidCallback onPressed; + + const LemmaDefinitionWidget({ + super.key, + required this.token, + required this.tokenLang, + required this.onPressed, + }); + + @override + LemmaDefinitionWidgetState createState() => LemmaDefinitionWidgetState(); +} + +class LemmaDefinitionWidgetState extends State { + late Future _definition; + + @override + void initState() { + super.initState(); + _definition = _fetchDefinition(); + } + + Future _fetchDefinition() async { + if (widget.token.shouldDoPosActivity) { + return '?'; + } else { + final res = await LemmaDictionaryRepo.get( + LemmaDefinitionRequest( + lemma: widget.token.lemma.text, + partOfSpeech: widget.token.pos, + lemmaLang: widget.tokenLang, + userL1: MatrixState + .pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + ), + ); + return res.definition; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _definition, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + // TODO better error widget + return Text('Error: ${snapshot.error}'); + } else { + return ActionChip( + avatar: const Icon(Icons.book), + label: Text(snapshot.data ?? 'No definition found'), + onPressed: widget.onPressed, + ); + } + }, + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/lemma_widget.dart b/lib/pangea/widgets/word_zoom/lemma_widget.dart new file mode 100644 index 000000000..4c5543695 --- /dev/null +++ b/lib/pangea/widgets/word_zoom/lemma_widget.dart @@ -0,0 +1,35 @@ +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:flutter/material.dart'; + +class LemmaWidget extends StatelessWidget { + final PangeaToken token; + final VoidCallback onPressed; + + final String? lemma; + final Function(String) setLemma; + + const LemmaWidget({ + super.key, + required this.token, + required this.onPressed, + this.lemma, + required this.setLemma, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: IconButton( + onPressed: () { + onPressed(); + if (lemma == null) { + setLemma(token.lemma.text); + } + }, + icon: Text(token.xpEmoji), + ), + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/morphological_widget.dart b/lib/pangea/widgets/word_zoom/morphological_widget.dart new file mode 100644 index 000000000..23b88f181 --- /dev/null +++ b/lib/pangea/widgets/word_zoom/morphological_widget.dart @@ -0,0 +1,236 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class ActivityMorph { + final String morphFeature; + final String morphTag; + bool revealed; + + ActivityMorph({ + required this.morphFeature, + required this.morphTag, + required this.revealed, + }); +} + +class MorphologicalListWidget extends StatefulWidget { + final PangeaToken token; + final String? selectedMorphFeature; + final Function(String?) setMorphFeature; + final int completedActivities; + + const MorphologicalListWidget({ + super.key, + required this.selectedMorphFeature, + required this.token, + required this.setMorphFeature, + required this.completedActivities, + }); + + @override + MorphologicalListWidgetState createState() => MorphologicalListWidgetState(); +} + +class MorphologicalListWidgetState extends State { + // TODO: make this is a list of morphological features icons based on MorphActivityGenerator.getSequence + // For each item in the sequence, + // if shouldDoActivity is true, show the template icon then stop + // if shouldDoActivity is false, show the actual icon and value then go to the next item + + final List _morphs = []; + + @override + void initState() { + super.initState(); + _setMorphs(); + } + + @override + void didUpdateWidget(covariant MorphologicalListWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.token != oldWidget.token) { + _setMorphs(); + } + + if (widget.completedActivities != oldWidget.completedActivities && + oldWidget.selectedMorphFeature != null) { + final oldSelectedMorphIndex = _morphs.indexWhere( + (morph) => morph.morphFeature == oldWidget.selectedMorphFeature, + ); + if (oldSelectedMorphIndex != -1 && + oldSelectedMorphIndex < _morphs.length) { + setState( + () => _morphs[oldSelectedMorphIndex].revealed = true, + ); + + final nextIndex = oldSelectedMorphIndex + 1; + if (nextIndex < _morphs.length) { + widget.setMorphFeature(_morphs[nextIndex].morphFeature); + } else { + widget.setMorphFeature(null); + } + } + } + } + + Future _setMorphs() async { + _morphs.clear(); + final morphEntries = widget.token.morph.entries.toList(); + for (final morphEntry in morphEntries) { + final morphFeature = morphEntry.key; + final morphTag = morphEntry.value; + final shouldDoActivity = widget.token.shouldDoMorphActivity(morphFeature); + final canGenerateDistractors = await widget.token.canGenerateDistractors( + ActivityTypeEnum.morphId, + morphFeature: morphFeature, + morphTag: morphTag, + ); + _morphs.add( + ActivityMorph( + morphFeature: morphFeature, + morphTag: morphTag, + revealed: !shouldDoActivity || !canGenerateDistractors, + ), + ); + } + + _morphs.sort((a, b) { + if (a.revealed && !b.revealed) { + return -1; + } else if (!a.revealed && b.revealed) { + return 1; + } + + if (a.morphFeature.toLowerCase() == "pos") { + return -1; + } else if (b.morphFeature.toLowerCase() == "pos") { + return 1; + } + + return a.morphFeature.compareTo(b.morphFeature); + }); + } + + List get _visibleMorphs { + final lastRevealedIndex = _morphs.lastIndexWhere((morph) => morph.revealed); + + // if none of the morphs are revealed, show only the first one + if (lastRevealedIndex == -1) { + return _morphs.take(1).toList(); + } + + // show all the revealed morphs + the first one with an activity + return _morphs.take(lastRevealedIndex + 2).toList(); + } + + // TODO Use the icons that Khue is creating + IconData _getIconForMorphFeature(String feature) { + // Define a function to get the icon based on the universal dependency morphological feature (key) + switch (feature.toLowerCase()) { + case 'number': + // google material 123 icon + return Icons.format_list_numbered; + case 'gender': + return Icons.wc; + case 'tense': + return Icons.access_time; + case 'mood': + return Icons.mood; + case 'person': + return Icons.person; + case 'case': + return Icons.format_list_bulleted; + case 'degree': + return Icons.trending_up; + case 'verbform': + return Icons.text_format; + case 'voice': + return Icons.record_voice_over; + case 'aspect': + return Icons.aspect_ratio; + case 'prontype': + return Icons.text_fields; + case 'numtype': + return Icons.format_list_numbered; + case 'poss': + return Icons.account_balance; + case 'reflex': + return Icons.refresh; + case 'foreign': + return Icons.language; + case 'abbr': + return Icons.text_format; + case 'nountype': + return Symbols.abc; + case 'pos': + return Symbols.toys_and_games; + default: + debugger(when: kDebugMode); + return Icons.help_outline; + } + } + + @override + Widget build(BuildContext context) { + return Wrap( + children: _visibleMorphs.map((morph) { + return Padding( + padding: const EdgeInsets.all(2.0), + child: MorphologicalActivityButton( + onPressed: widget.setMorphFeature, + morphCategory: morph.morphFeature, + icon: _getIconForMorphFeature(morph.morphFeature), + isUnlocked: morph.revealed, + isSelected: widget.selectedMorphFeature == morph.morphFeature, + ), + ); + }).toList(), + ); + } +} + +class MorphologicalActivityButton extends StatelessWidget { + final Function(String) onPressed; + final String morphCategory; + final IconData icon; + + final bool isUnlocked; + final bool isSelected; + + const MorphologicalActivityButton({ + required this.onPressed, + required this.morphCategory, + required this.icon, + this.isUnlocked = true, + this.isSelected = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Tooltip( + message: getMorphologicalCategoryCopy( + morphCategory, + context, + ), + child: Opacity( + opacity: (isUnlocked && !isSelected) ? 0.75 : 1, + child: IconButton( + onPressed: () => onPressed(morphCategory), + icon: Icon(icon), + color: isSelected ? Theme.of(context).colorScheme.primary : null, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/part_of_speech_widget.dart b/lib/pangea/widgets/word_zoom/part_of_speech_widget.dart new file mode 100644 index 000000000..12b828954 --- /dev/null +++ b/lib/pangea/widgets/word_zoom/part_of_speech_widget.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; + +class PartOfSpeechWidget extends StatefulWidget { + final PangeaToken token; + + const PartOfSpeechWidget({super.key, required this.token}); + + @override + _PartOfSpeechWidgetState createState() => _PartOfSpeechWidgetState(); +} + +class _PartOfSpeechWidgetState extends State { + late Future _partOfSpeech; + + @override + void initState() { + super.initState(); + _partOfSpeech = _fetchPartOfSpeech(); + } + + Future _fetchPartOfSpeech() async { + if (widget.token.shouldDoPosActivity) { + return '?'; + } else { + return widget.token.pos; + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _partOfSpeech, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else { + return ActionChip( + avatar: const Icon(Icons.label), + label: Text(snapshot.data ?? 'No part of speech found'), + onPressed: () { + // Handle chip click + }, + ); + } + }, + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart new file mode 100644 index 000000000..a5a814b73 --- /dev/null +++ b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart @@ -0,0 +1,289 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/emoji_practice_button.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_text_with_audio_button.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/contextual_translation_widget.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/lemma_widget.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/morphological_widget.dart'; +import 'package:flutter/material.dart'; + +enum WordZoomSelection { + translation, + emoji, +} + +class WordZoomWidget extends StatefulWidget { + final PangeaToken token; + final PangeaMessageEvent messageEvent; + final TtsController tts; + final MessageOverlayController overlayController; + + const WordZoomWidget({ + super.key, + required this.token, + required this.messageEvent, + required this.tts, + required this.overlayController, + }); + + @override + WordZoomWidgetState createState() => WordZoomWidgetState(); +} + +class WordZoomWidgetState extends State { + ActivityTypeEnum? _activityType; + + // morphological activities + String? _selectedMorphFeature; + + /// used to trigger a rebuild of the morph activity + /// button when a morph activity is completed + int completedMorphActivities = 0; + + // defintion activities + String? _definition; + + // lemma activities + String? _lemma; + + // emoji activities + String? _emoji; + + // whether activity type can be generated + Map canGenerateActivity = { + ActivityTypeEnum.morphId: true, + ActivityTypeEnum.wordMeaning: true, + ActivityTypeEnum.lemmaId: false, + ActivityTypeEnum.emoji: true, + }; + + Future _initCanGenerateActivity() async { + widget.token.canGenerateDistractors(ActivityTypeEnum.lemmaId).then((value) { + if (mounted) { + setState(() { + canGenerateActivity[ActivityTypeEnum.lemmaId] = value; + }); + } + }); + } + + @override + void initState() { + super.initState(); + _initCanGenerateActivity(); + } + + @override + void didUpdateWidget(covariant WordZoomWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.token != oldWidget.token) { + _clean(); + _initCanGenerateActivity(); + } + } + + bool _showActivityCard(ActivityTypeEnum? activityType) { + if (activityType == null) return false; + final shouldDo = widget.token.shouldDoActivity( + a: activityType, + feature: _selectedMorphFeature, + tag: _selectedMorphFeature == null + ? null + : widget.token.morph[_selectedMorphFeature], + ); + + return canGenerateActivity[activityType]! && shouldDo; + } + + void _clean() { + if (mounted) { + setState(() { + _activityType = null; + _selectedMorphFeature = null; + _definition = null; + _lemma = null; + _emoji = null; + }); + } + } + + void _setSelectedMorphFeature(String? feature) { + _selectedMorphFeature = _selectedMorphFeature == feature ? null : feature; + _setActivityType( + _selectedMorphFeature == null ? null : ActivityTypeEnum.morphId, + ); + } + + void _setActivityType(ActivityTypeEnum? activityType) { + if (mounted) setState(() => _activityType = activityType); + } + + void _setDefinition(String definition) { + if (mounted) setState(() => _definition = definition); + } + + void _setLemma(String lemma) { + if (mounted) setState(() => _lemma = lemma); + } + + void _setEmoji(String emoji) { + if (mounted) setState(() => _emoji = emoji); + } + + void onActivityFinish({ + required ActivityTypeEnum activityType, + String? correctAnswer, + }) { + switch (activityType) { + case ActivityTypeEnum.morphId: + if (mounted) setState(() => completedMorphActivities++); + break; + case ActivityTypeEnum.wordMeaning: + if (correctAnswer == null) return; + _setDefinition(correctAnswer); + break; + case ActivityTypeEnum.lemmaId: + if (correctAnswer == null) return; + _setLemma(correctAnswer); + break; + case ActivityTypeEnum.emoji: + if (correctAnswer == null) return; + widget.token + .setEmoji(correctAnswer) + .then((_) => _setEmoji(correctAnswer)); + break; + default: + break; + } + } + + Widget get _activityAnswer { + switch (_activityType) { + case ActivityTypeEnum.morphId: + if (_selectedMorphFeature == null) { + return const Text("There should be a selected morph feature"); + } + final String morphTag = widget.token.morph[_selectedMorphFeature!]; + final copy = getGrammarCopy( + category: _selectedMorphFeature!, + lemma: morphTag, + context: context, + ); + return Text(copy ?? morphTag); + case ActivityTypeEnum.wordMeaning: + return _definition != null + ? Text(_definition!) + : const Text("defintion is null"); + case ActivityTypeEnum.lemmaId: + return _lemma != null ? Text(_lemma!) : const Text("lemma is null"); + case ActivityTypeEnum.emoji: + return _emoji != null ? Text(_emoji!) : const Text("emoji is null"); + default: + return const SizedBox(); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: + const BoxConstraints(minHeight: AppConfig.toolbarMinHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: + const BoxConstraints(minWidth: AppConfig.toolbarMinWidth), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + EmojiPracticeButton( + emoji: _emoji, + token: widget.token, + onPressed: () => _setActivityType( + _activityType == ActivityTypeEnum.emoji + ? null + : ActivityTypeEnum.emoji, + ), + setEmoji: _setEmoji, + ), + WordTextWithAudioButton( + text: widget.token.text.content, + ttsController: widget.tts, + eventID: widget.messageEvent.eventId, + ), + LemmaWidget( + token: widget.token, + onPressed: () => _setActivityType( + _activityType == ActivityTypeEnum.lemmaId + ? null + : ActivityTypeEnum.lemmaId, + ), + lemma: _lemma, + setLemma: _setLemma, + ), + ], + ), + ), + if (_activityType != null) + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_showActivityCard(_activityType)) + PracticeActivityCard( + pangeaMessageEvent: widget.messageEvent, + targetTokensAndActivityType: + TargetTokensAndActivityType( + tokens: [widget.token], + activityType: _activityType!, + ), + overlayController: widget.overlayController, + morphFeature: _selectedMorphFeature, + wordDetailsController: this, + ) + else + _activityAnswer, + ], + ) + else + ContextualTranslationWidget( + token: widget.token, + fullText: widget.messageEvent.messageDisplayText, + langCode: widget.messageEvent.messageDisplayLangCode, + onPressed: () => + _setActivityType(ActivityTypeEnum.wordMeaning), + definition: _definition, + setDefinition: _setDefinition, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + MorphologicalListWidget( + token: widget.token, + setMorphFeature: _setSelectedMorphFeature, + selectedMorphFeature: _selectedMorphFeature, + completedActivities: completedMorphActivities, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 7e9941d7b..4e30e8052 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -117,6 +117,7 @@ abstract class ClientManager { PangeaEventTypes.botOptions, PangeaEventTypes.capacity, EventTypes.RoomPowerLevels, + PangeaEventTypes.userChosenEmoji, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose, diff --git a/pubspec.lock b/pubspec.lock index 6f660f1c8..fbe0ec460 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_levenshtein: + dependency: "direct main" + description: + name: dart_levenshtein + sha256: f38182278b774cbb0d5993de50848e65da5a9f722b7f07787f0c26de009e0322 + url: "https://pub.dev" + source: hosted + version: "1.0.1" dart_webrtc: dependency: "direct overridden" description: diff --git a/pubspec.yaml b/pubspec.yaml index d57765b2f..bb8181cb4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -142,6 +142,7 @@ dependencies: rive: 0.11.11 text_to_speech: ^0.2.3 flutter_tts: ^4.2.0 + dart_levenshtein: ^1.0.1 # Pangea# dev_dependencies: