From 379e4a8db93d2d09b3a223b6cb8bd307ceb38a48 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Mon, 24 Mar 2025 15:20:07 -0400 Subject: [PATCH] Reading assistance (#2175) * still in draft * feat(reading_assistance): whole message activity oriented * chore: fix .env file path * feat: animate selected toolbar into middle of screen * chore: initial work for message bubble size animation * refactor(reading_assistance): hooking up the choice interactions and polishing UI * chore: animate in content and buttons * formatting * position reading content relative to selected token * working on limiting choices * chore: fix positioning of toolbar animation * chore: simplify positioning logic * chore: animate in button height * getting there * rough draft with restricted activity number is complete --------- Co-authored-by: ggurdin --- assets/l10n/intl_en.arb | 12 +- assets/l10n/intl_es.arb | 5 +- assets/l10n/intl_vi.arb | 2 - lib/config/app_config.dart | 16 +- lib/pages/chat/events/html_message.dart | 18 +- lib/pages/chat/events/message_content.dart | 26 +- .../analytics_details_popup.dart | 2 + .../analytics_details_popup_content.dart | 2 +- .../morph_analytics_list_view.dart | 35 +- .../morph_details_view.dart | 151 +++--- .../vocab_analytics_details_view.dart | 196 +++----- .../vocab_analytics_list_tile.dart | 9 +- .../analytics_misc/construct_type_enum.dart | 2 +- .../construct_use_type_enum.dart | 8 +- .../get_analytics_controller.dart | 16 +- .../message_analytics_controller.dart | 265 ---------- .../utils/get_chat_list_item_subtitle.dart | 14 +- lib/pangea/common/constants/model_keys.dart | 1 + lib/pangea/common/utils/any_state_holder.dart | 6 + lib/pangea/common/utils/error_handler.dart | 25 +- lib/pangea/common/utils/overlay.dart | 41 +- lib/pangea/constructs/construct_form.dart | 21 + .../constructs/construct_identifier.dart | 222 +++++++-- lib/pangea/emojis/emoji_stack.dart | 36 ++ .../events/constants/pangea_event_types.dart | 1 + .../event_wrappers/pangea_message_event.dart | 11 +- .../pangea_representation_event.dart | 51 +- .../extensions/pangea_event_extension.dart | 10 +- .../events/models/pangea_token_model.dart | 286 ++++++----- .../models/pangea_token_text_model.dart | 2 + .../events/utils/message_text_util.dart | 39 +- .../instructions/instructions_enum.dart | 35 +- .../instructions_inline_tooltip.dart | 30 +- .../reset_instructions_list_tile.dart | 51 ++ .../pages/settings_learning.dart | 15 + .../pages/settings_learning_view.dart | 12 +- lib/pangea/lemmas/lemma_emoji_row.dart | 73 +++ lib/pangea/lemmas/lemma_info_repo.dart | 86 ++-- lib/pangea/lemmas/lemma_info_request.dart | 8 + lib/pangea/lemmas/lemma_info_response.dart | 8 +- lib/pangea/lemmas/user_set_lemma_info.dart | 34 ++ .../message_token_text/hidden_text.dart | 46 ++ .../message_token_button.dart | 241 +++++++++ .../token_position_model.dart | 29 ++ .../morphs/get_icon_for_morph_feature.dart | 57 --- lib/pangea/morphs/morph_categories_enum.dart | 140 ------ lib/pangea/morphs/morph_feature_display.dart | 29 +- lib/pangea/morphs/morph_features_enum.dart | 224 +++++++++ lib/pangea/morphs/morph_icon.dart | 50 +- lib/pangea/morphs/morph_tag_display.dart | 38 +- lib/pangea/morphs/parts_of_speech_enum.dart | 119 +++-- .../activity_display_instructions_enum.dart | 0 .../activity_type_enum.dart | 24 +- .../emoji_activity_generator.dart | 25 +- .../lemma_activity_generator.dart | 16 +- .../lemma_meaning_activity_generator.dart | 61 +-- .../message_activity_request.dart | 10 +- .../message_analytics_controller.dart | 283 +++++++++++ .../morph_activity_generator.dart | 16 +- .../multiple_choice_activity_model.dart | 8 +- .../practice_activity_model.dart | 18 +- .../practice_activity_record_model.dart | 7 +- .../practice_repo.dart | 24 +- .../target_tokens_and_activity_type.dart | 48 ++ ...eaning_static_practice_activity_model.dart | 8 +- .../toolbar/enums/message_mode_enum.dart | 287 +++++++++-- .../practice_activity_event.dart | 7 +- .../practice_activity_record_event.dart | 4 +- .../match_feedback_model.dart | 20 + .../message_emoji_choice.dart | 155 ------ .../message_emoji_choice_item.dart | 62 ++- .../message_match_activity.dart | 120 +++++ .../message_match_activity_item.dart | 165 +++++++ .../message_morph_choice.dart | 232 +++++++++ .../message_morph_choice_item.dart | 104 ++++ .../reading_assistance_input_bar.dart | 124 ++--- .../toolbar/widgets/measure_render_box.dart | 43 ++ .../toolbar/widgets/message_audio_card.dart | 94 +--- .../widgets/message_mode_locked_card.dart | 46 +- .../widgets/message_selection_overlay.dart | 459 +++++++++++------- .../widgets/message_selection_positioner.dart | 344 ++++++++----- .../toolbar/widgets/message_token_text.dart | 306 +++++++----- .../widgets/message_translation_card.dart | 54 +-- .../widgets/overlay_center_content.dart | 146 +++--- .../toolbar/widgets/overlay_message.dart | 257 +++++----- .../emoji_practice_button.dart | 7 +- .../multiple_choice_activity.dart | 31 +- .../practice_activity_card.dart | 44 +- .../practice_activity/word_audio_button.dart | 114 +++-- .../word_text_with_audio_button.dart | 7 +- .../word_zoom_activity_button.dart | 11 +- ...r.dart => reading_assistance_content.dart} | 127 ++--- .../toolbar/widgets/toolbar_button.dart | 16 +- ...> toolbar_button_and_progress_column.dart} | 39 +- .../widgets/toolbar_button_column.dart | 85 ++++ .../word_zoom/lemma_meaning_widget.dart | 239 +++++---- .../widgets/word_zoom/lemma_widget.dart | 53 +- .../morphs/morphological_center_widget.dart | 231 +++++---- .../morphs/morphological_list_item.dart | 83 ++-- .../word_zoom/word_zoom_selection_enum.dart | 2 +- .../widgets/word_zoom/word_zoom_widget.dart | 158 +++--- lib/pangea/word_bank/vocab_bank_repo.dart | 15 +- lib/pangea/word_bank/vocab_request.dart | 22 +- .../writing_assistance_input_row.dart | 196 ++++---- lib/utils/client_manager.dart | 2 +- 105 files changed, 5009 insertions(+), 2906 deletions(-) delete mode 100644 lib/pangea/analytics_misc/message_analytics_controller.dart create mode 100644 lib/pangea/constructs/construct_form.dart create mode 100644 lib/pangea/emojis/emoji_stack.dart create mode 100644 lib/pangea/instructions/reset_instructions_list_tile.dart create mode 100644 lib/pangea/lemmas/lemma_emoji_row.dart create mode 100644 lib/pangea/lemmas/user_set_lemma_info.dart create mode 100644 lib/pangea/message_token_text/hidden_text.dart create mode 100644 lib/pangea/message_token_text/message_token_button.dart create mode 100644 lib/pangea/message_token_text/token_position_model.dart delete mode 100644 lib/pangea/morphs/get_icon_for_morph_feature.dart delete mode 100644 lib/pangea/morphs/morph_categories_enum.dart create mode 100644 lib/pangea/morphs/morph_features_enum.dart rename lib/pangea/{toolbar/enums => practice_activities}/activity_display_instructions_enum.dart (100%) rename lib/pangea/{toolbar/enums => practice_activities}/activity_type_enum.dart (93%) rename lib/pangea/{toolbar/repo => practice_activities}/emoji_activity_generator.dart (62%) rename lib/pangea/{toolbar/repo => practice_activities}/lemma_activity_generator.dart (90%) rename lib/pangea/{toolbar/repo => practice_activities}/lemma_meaning_activity_generator.dart (60%) rename lib/pangea/{toolbar/models => practice_activities}/message_activity_request.dart (96%) create mode 100644 lib/pangea/practice_activities/message_analytics_controller.dart rename lib/pangea/{toolbar/repo => practice_activities}/morph_activity_generator.dart (89%) rename lib/pangea/{toolbar/models => practice_activities}/multiple_choice_activity_model.dart (97%) rename lib/pangea/{toolbar/models => practice_activities}/practice_activity_model.dart (96%) rename lib/pangea/{toolbar/models => practice_activities}/practice_activity_record_model.dart (98%) rename lib/pangea/{toolbar/repo => practice_activities}/practice_repo.dart (90%) create mode 100644 lib/pangea/practice_activities/target_tokens_and_activity_type.dart rename lib/pangea/{toolbar/repo => practice_activities}/word_meaning_static_practice_activity_model.dart (83%) create mode 100644 lib/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart delete mode 100644 lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice.dart create mode 100644 lib/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart create mode 100644 lib/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart create mode 100644 lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart create mode 100644 lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart create mode 100644 lib/pangea/toolbar/widgets/measure_render_box.dart rename lib/pangea/toolbar/widgets/{message_toolbar.dart => reading_assistance_content.dart} (54%) rename lib/pangea/toolbar/widgets/{toolbar_button_and_progress_row.dart => toolbar_button_and_progress_column.dart} (67%) create mode 100644 lib/pangea/toolbar/widgets/toolbar_button_column.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 755f8bbf2..b8cd93cb5 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4507,7 +4507,7 @@ }, "botModeValidation": "Please select a chat mode", "clickBestOption": "Choose the best options to translate your message!", - "completeActivitiesToUnlock": "Complete word activities to unlock audio and translation!", + "completeActivitiesToUnlock": "Complete one of the activities (emoji, meaning, listening OR grammar) to unlock the translation!", "botSettingsSubtitle": "Invite bot to moderate chat activity", "invitePeople": "Invite users", "noCapacityLimit": "No capacity limit", @@ -4590,7 +4590,7 @@ "pleaseEnterEmail": "Please enter a valid email address.", "pleaseSelectALanguage": "Please select a language", "myBaseLanguage": "My base language", - "clickWordsInstructions": "Click on a word or the buttons below to learn more", + "clickWordsInstructions": "🕵️ Click any word for details. 🧐", "chooseBestDefinition": "What does this word mean?", "meaningSectionHeader": "Meaning:", "formSectionHeader": "Forms used in chats:", @@ -4671,7 +4671,7 @@ } } }, - "lemmaMeaningInstructionsBody": "Above is the meaning of the lemma. Double-click to edit.", + "chooseLemmaMeaningInstructionsBody": "Match the meanings below with the underlined words in the message.", "doubleClickToEdit": "Double-click to edit.", "removeFeature": "Remove {feature}", "@removeFeature": { @@ -4711,7 +4711,6 @@ "video": "Video", "nan": "Not applicable", "activityPlannerOverviewInstructionsBody": "Choose a topic, mode, learning objective and generate an activity for the chat!", - "completeActivitiesToUnlock": "Complete the highlighted word activities to unlock", "myBookmarkedActivities": "My Bookmarked Activities", "noBookmarkedActivities": "No bookmarked activities", "activityTitle": "Activity Title", @@ -4793,7 +4792,7 @@ "downloadGboard": "Download Gboard", "autocorrectNotAvailable": "Unfortunately your platform is not currently supported for this feature. Stay tuned for further development!", "pleaseUpdateApp": "Please update the app to continue.", - "chooseEmojiInstructionsBody": "Pick an emoji for the word! There's no wrong answer and you can switch anytime! 😀", + "chooseEmojiInstructionsBody": "Match emojis with the words they best represent. Don't worry! No points off for disagreeing. 😅", "pickAnEmojiFor": "Pick an emoji for ${lemma}", "@pickAnEmojiFor": { "type": "String", @@ -4808,11 +4807,14 @@ "knockSpaceSuccess": "You have requested to join this space! An admin will respond to your request when they receive it 😀", "joinByCode": "Join by code", "createASpace": "Create a space", + "chooseWordAudioInstructionsBody": "Listen to the full message then match the word audios to the right blanks!", + "chooseMorphsInstructionsBody": "Match the grammar tags with the words in the message. Click and hold an option for a hint!", "inviteAndLaunch": "Launch and invite", "createOwnChat": "Create your own chat", "pleaseEnterInt": "Please enter a number", "home": "Home", "join": "Join", + "readingAssistanceOverviewBody": "Click the buttons below for mini-games on visualizing vocab, practice listening, meaning, and grammar concepts. Click any word for details.", "learnByTexting": "Learn by texting", "levelSummaryTrigger": "View summary", "levelSummaryPopupTitle": "Level {level} Summary", diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index bcf2b21c2..6499b8dfe 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -5144,7 +5144,6 @@ "other": "Otros", "botModeValidation": "Seleccione un modo de chat", "clickBestOption": "Elija las mejores opciones para traducir su mensaje", - "completeActivitiesToUnlock": "¡Completa actividades de palabras para desbloquear audio y traducción!", "botSettingsSubtitle": "Invitar a un bot a moderar la actividad del chat", "invitePeople": "Invitar a usuarios", "noCapacityLimit": "Sin límite de capacidad", @@ -5334,7 +5333,6 @@ } } }, - "lemmaMeaningInstructionsBody": "Arriba está el significado del lema. Haz doble clic para editar.", "doubleClickToEdit": "Haz doble clic para editar.", "removeFeature": "Eliminar {feature}", "@removeFeature": { @@ -5374,7 +5372,6 @@ "video": "Video", "nan": "No aplicable", "activityPlannerOverviewInstructionsBody": "¡Elige un tema, modo, objetivo de aprendizaje y genera una actividad para el chat!", - "completeActivitiesToUnlock": "Completa las actividades de palabras resaltadas para desbloquear", "myBookmarkedActivities": "Mis Actividades Marcadas", "noBookmarkedActivities": "No hay actividades marcadas", "activityTitle": "Título de la Actividad", @@ -5453,4 +5450,4 @@ }, "downloadGboard": "Descargar Gboard", "autocorrectNotAvailable": "Desafortunadamente, tu plataforma no es compatible actualmente con esta función. ¡Mantente atento a futuros desarrollos!" -} +} \ No newline at end of file diff --git a/assets/l10n/intl_vi.arb b/assets/l10n/intl_vi.arb index 705bf9d3b..27fc0c81a 100644 --- a/assets/l10n/intl_vi.arb +++ b/assets/l10n/intl_vi.arb @@ -3304,7 +3304,6 @@ }, "botModeValidation": "Vui lòng chọn một chế độ trò chuyện", "clickBestOption": "Chọn phương án tốt nhất để dịch tin nhắn của bạn!", - "completeActivitiesToUnlock": "Hoàn thành các hoạt động từ vựng được đánh dấu để mở khóa", "botSettingsSubtitle": "Mời bot kiểm duyệt hoặc khởi tạo hoạt động trò chuyện", "invitePeople": "Mời người dùng", "invitePeopleChatSubtitle": "Mời người dùng hoặc quản trị viên đến cuộc trò chuyện này", @@ -3527,7 +3526,6 @@ } } }, - "lemmaMeaningInstructionsBody": "Ở trên là nghĩa của mục từ. Nhấp đúp để chỉnh sửa.", "doubleClickToEdit": "Nhấp đúp để chỉnh sửa.", "removeFeature": "Xóa {feature}", "@removeFeature": { diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 23459b96e..2f3c74a47 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,8 +1,6 @@ -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; abstract class AppConfig { // #Pangea @@ -23,16 +21,14 @@ abstract class AppConfig { static const double messageFontSize = 16.0; static const bool allowOtherHomeservers = true; static const bool enableRegistration = true; - static const double toolbarMaxHeight = 175.0; - static const double toolbarMinHeight = 140.0; + static const double toolbarMaxHeight = 250.0; + static const double toolbarMinHeight = 200.0; static const double toolbarMinWidth = 350.0; - static const double toolbarButtonsColumnWidth = 50.0; - static const double toolbarButtonAndProgressColumnHeight = 200.0; static const double defaultHeaderHeight = 56.0; - static const double readingAssistanceInputBarHeight = 150; + static const double readingAssistanceInputBarHeight = 170; static const double toolbarSpacing = 8.0; static const double toolbarIconSize = 24.0; - static const double toolbarButtonsColumnHeight = 240; + static TextStyle messageTextStyle( Event? event, Color textColor, diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index a2d6ab76b..18cbe1184 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -1,6 +1,12 @@ -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_highlighter/flutter_highlighter.dart'; import 'package:flutter_highlighter/themes/shades-of-purple.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; @@ -8,13 +14,6 @@ import 'package:html/dom.dart' as dom; import 'package:html/parser.dart' as parser; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../utils/url_launcher.dart'; class HtmlMessage extends StatelessWidget { @@ -270,6 +269,7 @@ class HtmlMessage extends StatelessWidget { ) ?? false; + // @ggurdin: probably changing this, not sure when it shows up final didMeaningActivity = token?.didActivitySuccessfully( ActivityTypeEnum.wordMeaning, ) ?? diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 3ac3613b6..2e244c4bb 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -1,21 +1,21 @@ import 'dart:math'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_toolbar_selection_area.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:matrix/matrix.dart'; + import '../../../config/app_config.dart'; import '../../../utils/platform_infos.dart'; import '../../../utils/url_launcher.dart'; @@ -42,6 +42,7 @@ class MessageContent extends StatelessWidget { final ChatController controller; final Event? nextEvent; final Event? prevEvent; + final bool isTransitionAnimation; // Pangea# final Timeline timeline; @@ -58,6 +59,7 @@ class MessageContent extends StatelessWidget { required this.controller, this.nextEvent, this.prevEvent, + this.isTransitionAnimation = false, // Pangea# required this.linkColor, required this.borderRadius, @@ -377,6 +379,18 @@ class MessageContent extends StatelessWidget { style: messageTextStyle, onClick: onClick, isSelected: overlayController != null ? isSelected : null, + messageMode: overlayController?.toolbarMode, + isHighlighted: (PangeaToken token) => + overlayController!.toolbarMode.associatedActivityType != + null && + overlayController?.messageAnalyticsEntry?.hasActivity( + overlayController! + .toolbarMode.associatedActivityType!, + token, + ) == + true, + overlayController: overlayController, + isTransitionAnimation: isTransitionAnimation, ); } diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index f22d77195..f2ec166d0 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -36,6 +36,8 @@ class AnalyticsPopupWrapperState extends State { ConstructIdentifier? localConstructZoom; ConstructTypeEnum localView = ConstructTypeEnum.vocab; + // @ggurdin + //TODO: make language-specific MorphFeaturesAndTags morphs = defaultMorphMapping; List features = defaultMorphMapping.displayFeatures; diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart b/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart index 0bf03395a..f31030e4b 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart @@ -85,7 +85,7 @@ class AnalyticsDetailsViewContent extends StatelessWidget { construct: construct, category: LearningSkillsEnum.hearing, tooltip: L10n.of(context).listeningExercisesTooltip, - icon: Symbols.hearing, + icon: Icons.volume_up, ), // Reading exercise section LemmaUsageDots( diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index 67d140b1d..c7f2444f8 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -1,8 +1,4 @@ -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; @@ -12,9 +8,11 @@ import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/user/client_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class MorphAnalyticsListView extends StatelessWidget { final void Function(ConstructIdentifier) onConstructZoom; @@ -75,20 +73,8 @@ class MorphFeatureBox extends StatelessWidget { required this.onConstructZoom, }); - String _categoryCopy( - String category, - BuildContext context, - ) { - if (category.toLowerCase() == "other") { - return L10n.of(context).other; - } - - return ConstructTypeEnum.morph.getDisplayCopy( - category, - context, - ) ?? - category; - } + MorphFeaturesEnum get feature => + MorphFeaturesEnumExtension.fromString(morphFeature); @override Widget build(BuildContext context) { @@ -98,7 +84,7 @@ class MorphFeatureBox extends StatelessWidget { padding: const EdgeInsets.all(16.0), margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), border: Border.all( color: Theme.of(context).brightness == Brightness.dark ? AppConfig.primaryColorLight @@ -116,10 +102,10 @@ class MorphFeatureBox extends StatelessWidget { SizedBox( height: 30.0, width: 30.0, - child: MorphIcon(morphFeature: morphFeature, morphTag: null), + child: MorphIcon(morphFeature: feature, morphTag: null), ), Text( - _categoryCopy(morphFeature, context), + feature.getDisplayCopy(context), style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -193,13 +179,16 @@ class MorphTagChip extends StatelessWidget { this.onTap, }); + MorphFeaturesEnum get feature => + MorphFeaturesEnumExtension.fromString(morphFeature); + @override Widget build(BuildContext context) { final theme = Theme.of(context); final unlocked = constructAnalytics.points > 10; return InkWell( - borderRadius: BorderRadius.circular(32.0), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), onTap: onTap, child: Opacity( opacity: unlocked ? 1.0 : 0.3, @@ -231,7 +220,7 @@ class MorphTagChip extends StatelessWidget { height: 28.0, child: unlocked || Matrix.of(context).client.isSupportAccount ? MorphIcon( - morphFeature: morphFeature, + morphFeature: feature, morphTag: morphTag, ) : const Icon( diff --git a/lib/pangea/analytics_details_popup/morph_details_view.dart b/lib/pangea/analytics_details_popup/morph_details_view.dart index 28b883423..a3ec42cd7 100644 --- a/lib/pangea/analytics_details_popup/morph_details_view.dart +++ b/lib/pangea/analytics_details_popup/morph_details_view.dart @@ -1,17 +1,15 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; import 'package:fluffychat/pangea/morphs/morph_feature_display.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart'; import 'package:fluffychat/pangea/morphs/morph_tag_display.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class MorphDetailsView extends StatelessWidget { final ConstructIdentifier constructId; @@ -37,70 +35,91 @@ class MorphDetailsView extends StatelessWidget { : _construct.lemmaCategory.darkColor(context); return AnalyticsDetailsViewContent( - title: - MorphFeatureDisplay(morphFeature: _morphFeature, morphTag: _morphTag), - subtitle: - MorphTagDisplay(morphFeature: _morphFeature, textColor: textColor), + subtitle: MorphFeatureDisplay(morphFeature: _morphFeature), + title: MorphTagDisplay( + morphFeature: MorphFeaturesEnumExtension.fromString(_morphFeature), + morphTag: _morphTag, + textColor: textColor, + ), headerContent: Padding( - padding: const EdgeInsets.all(25.0), - child: Align( - alignment: Alignment.topLeft, - child: FutureBuilder( - future: _getDefinition(context), - builder: ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - if (snapshot.hasData) { - return MorphMeaningWidget( - feature: _morphFeature, - tag: _morphTag, - style: Theme.of(context).textTheme.bodyLarge, - leading: TextSpan( - text: L10n.of(context).meaningSectionHeader, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ); - } else if (snapshot.hasError) { - return Wrap( - children: [ - Text( - L10n.of(context).meaningSectionHeader, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox( - width: 10, - ), - Text( - L10n.of(context).meaningNotFound, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ); - } else { - return Wrap( - children: [ - Text( - L10n.of(context).meaningSectionHeader, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox( - width: 10, - ), - const TextLoadingShimmer(width: 100), - ], - ); - } - }, - ), + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MorphMeaningWidget( + feature: _morphFeature, + tag: _morphTag, + style: Theme.of(context).textTheme.bodyLarge, + // leading: TextSpan( + // text: L10n.of(context).meaningSectionHeader, + // style: const TextStyle( + // fontWeight: FontWeight.bold, + // ), + // ), + ), + ], ), ), + // headerContent: Padding( + // padding: const EdgeInsets.all(25.0), + // child: Align( + // alignment: Alignment.topLeft, + // child: FutureBuilder( + // future: _getDefinition(context), + // builder: ( + // BuildContext context, + // AsyncSnapshot snapshot, + // ) { + // if (snapshot.hasData) { + // return MorphMeaningWidget( + // feature: _morphFeature, + // tag: _morphTag, + // style: Theme.of(context).textTheme.bodyLarge, + // leading: TextSpan( + // text: L10n.of(context).meaningSectionHeader, + // style: const TextStyle( + // fontWeight: FontWeight.bold, + // ), + // ), + // ); + // } else if (snapshot.hasError) { + // return Wrap( + // children: [ + // Text( + // L10n.of(context).meaningSectionHeader, + // style: Theme.of(context).textTheme.bodyLarge?.copyWith( + // fontWeight: FontWeight.bold, + // ), + // ), + // const SizedBox( + // width: 10, + // ), + // Text( + // L10n.of(context).meaningNotFound, + // style: Theme.of(context).textTheme.bodyLarge, + // ), + // ], + // ); + // } else { + // return Wrap( + // children: [ + // Text( + // L10n.of(context).meaningSectionHeader, + // style: Theme.of(context).textTheme.bodyLarge?.copyWith( + // fontWeight: FontWeight.bold, + // ), + // ), + // const SizedBox( + // width: 10, + // ), + // const TextLoadingShimmer(width: 100), + // ], + // ); + // } + // }, + // ), + // ), + // ), xpIcon: ConstructXpWidget(id: constructId), constructId: constructId, ); diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 9056e5a73..ba22a92f3 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -1,20 +1,16 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:material_symbols_icons/symbols.dart'; - import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; -import 'package:fluffychat/pangea/lemmas/lemma.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_emoji_row.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; /// Displays information about selected lemma, and its usage class VocabDetailsView extends StatelessWidget { @@ -27,41 +23,6 @@ class VocabDetailsView extends StatelessWidget { ConstructUses get _construct => constructId.constructUses; - String? get _emoji => PangeaToken( - text: PangeaTokenText( - offset: 0, - content: _construct.lemma, - length: _construct.lemma.length, - ), - lemma: Lemma( - text: _construct.lemma, - saveVocab: false, - form: _construct.lemma, - ), - pos: _construct.category, - morph: {}, - ).getEmoji(); - - /// Get string representing forms of the given lemma that have been used - String? get _formString { - // Get possible forms of lemma - final constructs = MatrixState - .pangeaController.getAnalytics.constructListModel - .getConstructUsesByLemma(_construct.lemma); - - final forms = constructs - .map((e) => e.uses) - .expand((element) => element) - .where((use) => use.useType.pointValue > 0) - .map((e) => e.form?.toLowerCase()) - .toSet() - .whereType() - .toList(); - - if (forms.isEmpty) return null; - return forms.join(", "); - } - /// Get the language code for the current lemma String? get _userL2 => MatrixState.pangeaController.languageController.userL2?.langCode; @@ -75,101 +36,102 @@ class VocabDetailsView extends StatelessWidget { return AnalyticsDetailsViewContent( title: Row( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox( - width: 42, - child: _emoji == null - ? Tooltip( - message: L10n.of(context).noEmojiSelectedTooltip, - child: Icon( - Icons.add_reaction_outlined, - size: 24, - color: textColor.withValues(alpha: 0.7), - ), - ) - : Text(_emoji!), - ), - const SizedBox(width: 10.0), - Text( - _construct.lemma, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(width: 10.0), WordAudioButton( text: _construct.lemma, size: 24, ), + const SizedBox(width: 4.0), + Text( + _construct.lemma, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: textColor, + ), + ), ], ), subtitle: Row( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - Tooltip( - message: L10n.of(context).grammarCopyPOS, - child: Icon( - Symbols.toys_and_games, - size: 23, - color: textColor.withValues(alpha: 0.7), - ), + MorphIcon( + morphFeature: MorphFeaturesEnum.Pos, + morphTag: _construct.category, ), - const SizedBox(width: 10.0), + const SizedBox(width: 4.0), Text( - getGrammarCopy( - category: "pos", - lemma: _construct.category, - context: context, - ) ?? - _construct.category, - style: Theme.of(context).textTheme.titleMedium?.copyWith( + MorphFeaturesEnum.Pos.getDisplayCopy(context), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: textColor, ), ), ], ), headerContent: Padding( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), child: Column( + spacing: 8.0, children: [ - Padding( - padding: const EdgeInsets.all(5.0), - child: Align( - alignment: Alignment.topLeft, - child: _userL2 == null - ? Text(L10n.of(context).meaningNotFound) - : LemmaMeaningWidget( - constructUse: _construct, - langCode: _userL2!, - controller: null, - token: null, - style: Theme.of(context).textTheme.bodyLarge, - leading: TextSpan( - text: L10n.of(context).meaningSectionHeader, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LemmaEmojiRow( + cId: constructId, + onTap: () => {}, + removeCallback: null, + ), + ], + ), + Align( + alignment: Alignment.topLeft, + child: _userL2 == null + ? Text(L10n.of(context).meaningNotFound) + : LemmaMeaningWidget( + constructUse: _construct, + langCode: _userL2!, + controller: null, + token: null, + style: Theme.of(context).textTheme.bodyLarge, + leading: TextSpan( + text: L10n.of(context).meaningSectionHeader, + style: const TextStyle( + fontWeight: FontWeight.bold, ), ), - ), + ), ), - Padding( - padding: const EdgeInsets.all(5.0), - child: Align( - alignment: Alignment.topLeft, - child: Row( - children: [ - Text( - L10n.of(context).formSectionHeader, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - " ${_formString ?? L10n.of(context).formsNotFound}", - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), + Align( + alignment: Alignment.topLeft, + child: Wrap( + alignment: WrapAlignment.start, + runAlignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + L10n.of(context).formSectionHeader, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ...MatrixState + .pangeaController.getAnalytics.constructListModel + .getConstructUsesByLemma(_construct.lemma) + .map((e) => e.uses) + .expand((element) => element) + .map((e) => e.form?.toLowerCase()) + .toSet() + .whereType() + .map( + (form) => WordTextWithAudioButton( + text: form, + textSize: Theme.of(context) + .textTheme + .bodyMedium + ?.fontSize ?? + 16, + ), + ), + ], ), ), ], diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart index a6b77f8dc..44c26799e 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_list_tile.dart @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart'; +import 'package:flutter/material.dart'; class VocabAnalyticsListTile extends StatefulWidget { const VocabAnalyticsListTile({ @@ -53,10 +52,10 @@ class VocabAnalyticsListTileState extends State { height: (maxWidth - padding * 2) * 0.6, child: Opacity( opacity: - widget.constructUse.id.userSetEmoji == null ? 0.2 : 1, - child: widget.constructUse.id.userSetEmoji != null + widget.constructUse.id.userSetEmoji.isEmpty ? 0.2 : 1, + child: widget.constructUse.id.userSetEmoji.isNotEmpty ? Text( - widget.constructUse.id.userSetEmoji!, + widget.constructUse.id.userSetEmoji.first, style: const TextStyle( fontSize: 22, ), diff --git a/lib/pangea/analytics_misc/construct_type_enum.dart b/lib/pangea/analytics_misc/construct_type_enum.dart index 6940786d4..333f19b78 100644 --- a/lib/pangea/analytics_misc/construct_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_type_enum.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; enum ConstructTypeEnum { diff --git a/lib/pangea/analytics_misc/construct_use_type_enum.dart b/lib/pangea/analytics_misc/construct_use_type_enum.dart index 3100140c9..a54463027 100644 --- a/lib/pangea/analytics_misc/construct_use_type_enum.dart +++ b/lib/pangea/analytics_misc/construct_use_type_enum.dart @@ -1,10 +1,8 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_downloads/analytics_summary_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; enum ConstructUseTypeEnum { /// produced in chat by user, igc was run, and we've judged it to be a correct use diff --git a/lib/pangea/analytics_misc/get_analytics_controller.dart b/lib/pangea/analytics_misc/get_analytics_controller.dart index 727547f9e..398003ed5 100644 --- a/lib/pangea/analytics_misc/get_analytics_controller.dart +++ b/lib/pangea/analytics_misc/get_analytics_controller.dart @@ -1,18 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:flutter/material.dart'; - -import 'package:get_storage/get_storage.dart'; -import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/constants/local.key.dart'; import 'package:fluffychat/pangea/common/controllers/base_controller.dart'; @@ -23,6 +16,11 @@ import 'package:fluffychat/pangea/constructs/construct_repo.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; +import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:matrix/matrix.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; /// A minimized version of AnalyticsController that get the logged in user's analytics class GetAnalyticsController extends BaseController { @@ -42,10 +40,6 @@ class GetAnalyticsController extends BaseController { GetAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; - - perMessage = MessageAnalyticsController( - this, - ); } LanguageModel? get _l2 => _pangeaController.languageController.userL2; diff --git a/lib/pangea/analytics_misc/message_analytics_controller.dart b/lib/pangea/analytics_misc/message_analytics_controller.dart deleted file mode 100644 index 3a51dbf52..000000000 --- a/lib/pangea/analytics_misc/message_analytics_controller.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; - -/// Picks which tokens to do activities on and what types of activities to do -/// Caches result so that we don't have to recompute it -/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated -/// If we decided that the first token should have a hidden word listening, we need to remember that -/// Otherwise, the user might leave the chat, return, and see a different word hidden - -class TargetTokensAndActivityType { - final List tokens; - final ActivityTypeEnum activityType; - - TargetTokensAndActivityType({ - required this.tokens, - required this.activityType, - }); - - bool matchesActivity(PracticeActivityModel activity) { - // check if the existing activity has the same type as the target - if (activity.activityType != activityType) { - return false; - } - - // This is kind of complicated - // if it's causing problems, - // maybe we just verify that the target span of the activity is the same as the target span of the target? - final List relevantConstructs = tokens - .map((t) => t.constructs) - .expand((e) => e) - .map((c) => c.id) - .where(activityType.constructFilter) - .toList(); - - final List? otherRelevantConstructs = activity - .targetTokens - ?.map((t) => t.constructs) - .expand((e) => e) - .map((c) => c.id) - .where(activityType.constructFilter) - .toList(); - - return listEquals(otherRelevantConstructs, relevantConstructs); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TargetTokensAndActivityType && - listEquals(other.tokens, tokens) && - other.activityType == activityType; - } - - @override - int get hashCode => tokens.hashCode ^ activityType.hashCode; -} - -class MessageAnalyticsEntry { - final DateTime createdAt = DateTime.now(); - - late final List _tokens; - - final List _activityQueue = []; - - final int _maxQueueLength = 3; - - MessageAnalyticsEntry({ - required List tokens, - required bool includeHiddenWordActivities, - required PangeaMessageEvent pangeaMessageEvent, - }) { - _tokens = tokens; - 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 (_activityQueue.isNotEmpty) _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; - - bool get hasHiddenWordActivity => - nextActivity?.activityType.hiddenType ?? false; - - bool get hasMessageMeaningActivity => _activityQueue - .any((a) => a.activityType == ActivityTypeEnum.messageMeaning); - - 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 - void setActivityQueue() { - final List queue = []; - - // if applicable, add a hidden word activity to the front of the queue - final hiddenWordActivity = getHiddenWordActivity(queue.length); - if (hiddenWordActivity != null) { - _pushQueue(hiddenWordActivity); - } - } - - /// Add a message meaning activity to the front of the queue - /// And limits to _maxQueueLength activities - void addMessageMeaningActivity() { - final entry = TargetTokensAndActivityType( - tokens: _tokens, - activityType: ActivityTypeEnum.messageMeaning, - ); - _pushQueue(entry); - } - - /// Returns a hidden word activity if there is a sequence of tokens that have hiddenWordListening in their eligibleActivityTypes - TargetTokensAndActivityType? getHiddenWordActivity(int numOtherActivities) { - return null; - - // don't do hidden word listening on own messages - // if (!_includeHiddenWordActivities) { - // return null; - // } - - // // we will only do hidden word listening 30% of the time - // // if there are no other activities to do, we will always do hidden word listening - // if (Random().nextDouble() < 0.7) { - // // if (numOtherActivities >= _maxQueueLength && Random().nextDouble() < 0.5) { - // return null; - // } - - // // We will find the longest sequence of tokens that have hiddenWordListening in their eligibleActivityTypes - // final List> sequences = []; - // List currentSequence = []; - // for (final token in _tokens) { - // if (_pangeaMessageEvent.shouldDoActivity( - // token: token, - // a: ActivityTypeEnum.hiddenWordListening, - // feature: null, - // tag: null, - // )) { - // currentSequence.add(token); - // } else { - // if (currentSequence.isNotEmpty) { - // sequences.add(currentSequence); - // currentSequence = []; - // } - // } - // } - - // if (sequences.isEmpty) { - // return null; - // } - - // final longestSequence = sequences.reduce( - // (a, b) => a.length > b.length ? a : b, - // ); - - // // Truncate the sequence to a maximum of 2 words - // final truncatedSequence = longestSequence.take(2).toList(); - - // return TargetTokensAndActivityType( - // tokens: truncatedSequence, - // activityType: ActivityTypeEnum.hiddenWordListening, - // ); - } - - void onActivityComplete() => _popQueue(); - - void exitPracticeFlow() => _clearQueue(); - - void revealAllTokens() => _filterQueue(ActivityTypeEnum.hiddenWordListening); - - bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any( - (activity) => - activity.tokens.contains(token) && activity.activityType.hiddenType, - ); -} - -/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message -/// listens for analytics updates and updates the cache accordingly -class MessageAnalyticsController { - final GetAnalyticsController getAnalytics; - final Map _cache = {}; - - MessageAnalyticsController(this.getAnalytics); - - void dispose() { - _cache.clear(); - } - - // if over 50, remove oldest 5 entries by createdAt - void clean() { - if (_cache.length > 50) { - final sortedEntries = _cache.entries.toList() - ..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt)); - for (var i = 0; i < 5; i++) { - _cache.remove(sortedEntries[i].key); - } - } - } - - String _key(List tokens) => PangeaToken.reconstructText(tokens); - - MessageAnalyticsEntry? get( - List tokens, - PangeaMessageEvent pangeaMessageEvent, - ) { - final String key = _key(tokens); - - if (_cache.containsKey(key)) { - return _cache[key]; - } - - final bool includeHiddenWordActivities = !pangeaMessageEvent.ownMessage && - pangeaMessageEvent.messageDisplayRepresentation?.tokens != null && - pangeaMessageEvent.messageDisplayLangIsL2 && - !pangeaMessageEvent.event.isRichMessage; - - _cache[key] = MessageAnalyticsEntry( - tokens: tokens, - includeHiddenWordActivities: includeHiddenWordActivities, - pangeaMessageEvent: pangeaMessageEvent, - ); - - clean(); - - return _cache[key]; - } -} diff --git a/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart b/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart index c4142deba..59c323696 100644 --- a/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart +++ b/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart @@ -1,12 +1,12 @@ +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import '../../../utils/matrix_sdk_extensions/matrix_locals.dart'; class ChatListItemSubtitle extends StatelessWidget { @@ -88,7 +88,7 @@ class ChatListItemSubtitle extends StatelessWidget { final tokens = messageEventAndTokens.tokens; final analyticsEntry = tokens != null - ? MatrixState.pangeaController.getAnalytics.perMessage.get( + ? MessageAnalyticsController.get( tokens, pangeaMessageEvent, ) @@ -96,7 +96,7 @@ class ChatListItemSubtitle extends StatelessWidget { return MessageTextWidget( pangeaMessageEvent: pangeaMessageEvent, - style: style, + existingStyle: style, messageAnalyticsEntry: analyticsEntry, isSelected: null, onClick: null, diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 9d77e7594..ecee7f939 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -151,6 +151,7 @@ class ModelKey { static const String latestBuildNumber = "latest_build_number"; static const String mandatoryUpdate = "mandatory_update"; static const String emoji = "emoji"; + static const String emojiList = "emoji_list"; static const String analytics = "analytics"; static const String level = "level"; diff --git a/lib/pangea/common/utils/any_state_holder.dart b/lib/pangea/common/utils/any_state_holder.dart index dbd309a93..e1947d94d 100644 --- a/lib/pangea/common/utils/any_state_holder.dart +++ b/lib/pangea/common/utils/any_state_holder.dart @@ -38,6 +38,12 @@ class PangeaAnyState { } void disposeByWidgetKey(String transformTargetId) { + final index = + entries.indexWhere((element) => element.key == transformTargetId); + if (index != -1) { + entries[index].entry.remove(); + entries.removeAt(index); + } _layerLinkAndKeys.remove(transformTargetId); } diff --git a/lib/pangea/common/utils/error_handler.dart b/lib/pangea/common/utils/error_handler.dart index 325f0d0af..f5c66a139 100644 --- a/lib/pangea/common/utils/error_handler.dart +++ b/lib/pangea/common/utils/error_handler.dart @@ -7,7 +7,6 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:http/http.dart' as http; import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/utils/platform_infos.dart'; class PangeaWarningError implements Exception { @@ -22,18 +21,18 @@ class ErrorHandler { ErrorHandler(); static Future initialize() async { - await SentryFlutter.init( - (options) { - options.dsn = Environment.sentryDsn; - options.tracesSampleRate = 0.1; - options.debug = kDebugMode; - options.environment = kDebugMode - ? "debug" - : Environment.isStaging - ? "staging" - : "productionC"; - }, - ); + // await SentryFlutter.init( + // (options) { + // options.dsn = Environment.sentryDsn; + // options.tracesSampleRate = 0.1; + // options.debug = kDebugMode; + // options.environment = kDebugMode + // ? "debug" + // : Environment.isStaging + // ? "staging" + // : "productionC"; + // }, + // ); // Error handling FlutterError.onError = (FlutterErrorDetails details) async { diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index 81e8b3bf3..d626b3dd2 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -101,6 +101,7 @@ class OverlayUtil { bool closePrevOverlay = true, String? overlayKey, bool isScrollable = true, + bool addBorder = true, }) { try { final LayerLinkAndKey layerLinkAndKey = @@ -119,17 +120,21 @@ class OverlayUtil { final Offset transformTargetOffset = (targetRenderBox).localToGlobal(Offset.zero); final Size transformTargetSize = targetRenderBox.size; - final horizontalMidpoint = - transformTargetOffset.dx + (transformTargetSize.width / 2); + + final columnWidth = FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth + : 0; + + final horizontalMidpoint = (transformTargetOffset.dx - columnWidth) + + (transformTargetSize.width / 2); final verticalMidpoint = transformTargetOffset.dy + (transformTargetSize.height / 2); - debugPrint("vertical midpoint $verticalMidpoint"); final halfMaxWidth = maxWidth / 2; final hasLeftOverflow = (horizontalMidpoint - halfMaxWidth) < 0; final hasRightOverflow = (horizontalMidpoint + halfMaxWidth) > - MediaQuery.of(context).size.width; + (MediaQuery.of(context).size.width - columnWidth); hasTopOverflow = (verticalMidpoint - maxHeight) < 0; double xOffset = 0; @@ -138,24 +143,26 @@ class OverlayUtil { if (hasLeftOverflow) { xOffset = (horizontalMidpoint - halfMaxWidth - 10) * -1; } else if (hasRightOverflow) { - xOffset = MediaQuery.of(context).size.width - + xOffset = (MediaQuery.of(context).size.width - columnWidth) - (horizontalMidpoint + halfMaxWidth + 10); } offset = Offset(xOffset, 0); } - final Widget child = Material( - borderOnForeground: false, - color: Colors.transparent, - clipBehavior: Clip.antiAlias, - child: OverlayContainer( - cardToShow: cardToShow, - borderColor: borderColor, - maxHeight: maxHeight, - maxWidth: maxWidth, - isScrollable: isScrollable, - ), - ); + final Widget child = addBorder + ? Material( + borderOnForeground: false, + color: Colors.transparent, + clipBehavior: Clip.antiAlias, + child: OverlayContainer( + cardToShow: cardToShow, + borderColor: borderColor, + maxHeight: maxHeight, + maxWidth: maxWidth, + isScrollable: isScrollable, + ), + ) + : cardToShow; showOverlay( context: context, diff --git a/lib/pangea/constructs/construct_form.dart b/lib/pangea/constructs/construct_form.dart new file mode 100644 index 000000000..949657155 --- /dev/null +++ b/lib/pangea/constructs/construct_form.dart @@ -0,0 +1,21 @@ +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; + +class ConstructForm { + String form; + ConstructIdentifier cId; + + ConstructForm( + this.form, + this.cId, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ConstructForm && other.form == form && other.cId == cId; + } + + @override + int get hashCode => form.hashCode ^ cId.hashCode; +} diff --git a/lib/pangea/constructs/construct_identifier.dart b/lib/pangea/constructs/construct_identifier.dart index 823ce429e..5c3556e06 100644 --- a/lib/pangea/constructs/construct_identifier.dart +++ b/lib/pangea/constructs/construct_identifier.dart @@ -1,21 +1,27 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; -import 'package:matrix/matrix_api_lite/utils/try_get_map_extension.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; -import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/emojis/emoji_stack.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class ConstructIdentifier { final String lemma; @@ -111,28 +117,49 @@ class ConstructIdentifier { uses: [], ); - String? get userSetEmoji { - if (type == ConstructTypeEnum.morph) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: Exception("Morphs should not have userSetEmoji"), - data: toJson(), - ); - return null; + List get userSetEmoji => userLemmaInfo?.emojis ?? []; + + String? get userSetMeaning => userLemmaInfo?.meaning; + + UserSetLemmaInfo? get userLemmaInfo { + switch (type) { + case ConstructTypeEnum.vocab: + final dynamic lemmaInfoContent = MatrixState + .pangeaController.matrixState.client + .analyticsRoomLocal() + ?.getState(PangeaEventTypes.userSetLemmaInfo, string) + ?.content; + if (lemmaInfoContent != null && lemmaInfoContent is Map) { + try { + return UserSetLemmaInfo.fromJson( + lemmaInfoContent as Map, + ); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + data: { + "construct": string, + "content": lemmaInfoContent, + }, + s: s, + ); + return null; + } + } else { + return null; + } + case ConstructTypeEnum.morph: + debugger(when: kDebugMode); + ErrorHandler.logError( + e: Exception("Morphs should not have userSetEmoji"), + data: toJson(), + ); + return null; } - if (type == ConstructTypeEnum.vocab) { - return MatrixState.pangeaController.matrixState.client - .analyticsRoomLocal() - ?.getState(PangeaEventTypes.userChosenEmoji, string) - ?.content - .tryGet(ModelKey.emoji); - } - return null; } - /// [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 { + Future setUserLemmaInfo(UserSetLemmaInfo newLemmaInfo) async { final analyticsRoom = MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(); if (analyticsRoom == null) return; @@ -140,40 +167,151 @@ class ConstructIdentifier { final client = MatrixState.pangeaController.matrixState.client; final syncFuture = client.onRoomState.stream.firstWhere((event) { return event.roomId == analyticsRoom.id && - event.state.type == PangeaEventTypes.userChosenEmoji; + event.state.type == PangeaEventTypes.userSetLemmaInfo; }); + client.setRoomStateWithKey( analyticsRoom.id, - PangeaEventTypes.userChosenEmoji, + PangeaEventTypes.userSetLemmaInfo, string, - {ModelKey.emoji: emoji}, + UserSetLemmaInfo( + emojis: newLemmaInfo.emojis ?? userLemmaInfo?.emojis, + meaning: newLemmaInfo.meaning ?? userLemmaInfo?.meaning, + ).toJson(), ); await syncFuture; } catch (err, s) { debugger(when: kDebugMode); ErrorHandler.logError( e: err, - data: { - "construct": string, - "emoji": emoji, - }, + data: newLemmaInfo.toJson(), s: s, ); } } - // [getEmojiChoices] gets the emoji choices for the lemma - // assumes that the language of the lemma is the same as the user's current l2 - Future> getEmojiChoices() => LemmaInfoRepo.get( + /// [lemmmaLang] if not set, assumed to be userL2 + Future getLemmaInfo([ + String? lemmaLang, + String? userl1, + ]) => + LemmaInfoRepo.get( LemmaInfoRequest( - lemma: lemma, partOfSpeech: category, - lemmaLang: MatrixState - .pangeaController.languageController.userL2?.langCode ?? - LanguageKeys.unknownLanguage, - userL1: MatrixState - .pangeaController.languageController.userL1?.langCode ?? + lemmaLang: lemmaLang ?? + MatrixState + .pangeaController.languageController.userL2?.langCodeShort ?? LanguageKeys.defaultLanguage, + userL1: userl1 ?? + MatrixState + .pangeaController.languageController.userL1?.langCodeShort ?? + LanguageKeys.defaultLanguage, + lemma: lemma, ), - ).then((onValue) => onValue.emoji); + ); + + bool get isContentWord => + PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false; + + /// [form] should be passed if available and is required for morphId + bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a, String? form) { + switch (a) { + case ActivityTypeEnum.wordMeaning: + final double contentModifier = isContentWord ? 0.5 : 1; + if (daysSinceLastEligibleUseForMeaning < + 3 * constructUses.points * contentModifier) { + return false; + } + + return true; + case ActivityTypeEnum.emoji: + return userSetEmoji.length < maxEmojisPerLemma; + case ActivityTypeEnum.morphId: + if (form == null) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: Exception( + "form is null in isActivityProbablyLevelAppropriate for morphId", + ), + data: { + "activity": a, + "construct": toJson(), + }, + ); + return false; + } + final uses = constructUses.uses + .where((u) => u.form == form) + .map((u) => u.timeStamp) + .toList(); + + if (uses.isEmpty) return true; + + final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b); + + return DateTime.now().difference(lastUsed).inDays > + 1 * constructUses.points; + case ActivityTypeEnum.wordFocusListening: + final pos = PartOfSpeechEnumExtensions.fromString(lemma) ?? + PartOfSpeechEnumExtensions.fromString(category); + + if (pos == null) { + debugger(when: kDebugMode); + return false; + } + + return pos.canBeHeard; + default: + debugger(when: kDebugMode); + ErrorHandler.logError( + e: Exception( + "Activity type $a not handled in ConstructIdentifier.isActivityProbablyLevelAppropriate", + ), + data: { + "activity": a, + "construct": toJson(), + }, + ); + return false; + } + } + + /// days since last eligible use for meaning + /// this is the number of days since the last time the user used this word + /// in a way that would engage with the meaning of the word + /// importantly, this excludes emoji activities + /// we want users to be able to do an emoji activity as a ramp up to + /// a word meaning activity + int get daysSinceLastEligibleUseForMeaning { + final times = constructUses.uses + .where( + (u) => + u.useType.sentByUser || + ActivityTypeEnum.wordMeaning.associatedUseTypes + .contains(u.useType) || + ActivityTypeEnum.messageMeaning.associatedUseTypes + .contains(u.useType), + ) + .map((u) => u.timeStamp) + .toList(); + + if (times.isEmpty) return 1000; + + // return the most recent timestamp + final last = times.reduce((a, b) => a.isAfter(b) ? a : b); + + return DateTime.now().difference(last).inDays; + } + + Widget get visual { + switch (type) { + case ConstructTypeEnum.vocab: + return EmojiStack(emoji: userSetEmoji); + case ConstructTypeEnum.morph: + return MorphIcon( + morphFeature: MorphFeaturesEnumExtension.fromString(category), + morphTag: lemma, + ); + } + } } diff --git a/lib/pangea/emojis/emoji_stack.dart b/lib/pangea/emojis/emoji_stack.dart new file mode 100644 index 000000000..962229f4b --- /dev/null +++ b/lib/pangea/emojis/emoji_stack.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class EmojiStack extends StatelessWidget { + const EmojiStack({ + super.key, + required List emoji, + this.style, + }) : _emoji = emoji; + + final List _emoji; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + // return Text( + // _emoji.first, + // style: style, + // ); + return Text( + _emoji.join(''), + style: style, + ); + // return Stack( + // children: [ + // for (final emoji in _emoji) + // Positioned( + // left: _emoji.indexOf(emoji) * style!.fontSize! * 0.5, + // child: Text( + // emoji, + // style: style, + // ), + // ), + // ], + // ); + } +} diff --git a/lib/pangea/events/constants/pangea_event_types.dart b/lib/pangea/events/constants/pangea_event_types.dart index 3e0030d0e..a3e3c46a6 100644 --- a/lib/pangea/events/constants/pangea_event_types.dart +++ b/lib/pangea/events/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 userSetLemmaInfo = "p.user_lemma_info"; static const constructSummary = "pangea.construct_summary"; static const userChosenEmoji = "p.emoji"; diff --git a/lib/pangea/events/event_wrappers/pangea_message_event.dart b/lib/pangea/events/event_wrappers/pangea_message_event.dart index e5dc98e48..5919c108a 100644 --- a/lib/pangea/events/event_wrappers/pangea_message_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_message_event.dart @@ -1,12 +1,7 @@ import 'dart:convert'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; -import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_repo.dart'; @@ -17,13 +12,17 @@ import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart' import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/spaces/models/space_model.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/audio_encoding_enum.dart'; import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + import '../../../widgets/matrix.dart'; import '../../choreographer/enums/use_type.dart'; import '../../common/utils/error_handler.dart'; diff --git a/lib/pangea/events/event_wrappers/pangea_representation_event.dart b/lib/pangea/events/event_wrappers/pangea_representation_event.dart index bce186fbf..b9957e644 100644 --- a/lib/pangea/events/event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/events/event_wrappers/pangea_representation_event.dart @@ -2,13 +2,7 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; -import 'package:matrix/matrix.dart'; -import 'package:matrix/src/utils/markdown.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/choreographer/event_wrappers/pangea_choreo_event.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/models/language_detection_model.dart'; @@ -20,7 +14,14 @@ import 'package:fluffychat/pangea/events/models/representation_content_model.dar import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/events/repo/token_api_models.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; +import 'package:matrix/src/utils/markdown.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class RepresentationEvent { Event? _event; @@ -283,4 +284,42 @@ class RepresentationEvent { } return null; } + + List get tokensToSave => + tokens?.where((token) => token.lemma.saveVocab).toList() ?? []; + + // List get allTokenMorphsToConstructIdentifiers => tokens?.map((t) => t.morphConstructIds).toList() ?? + // []; + + /// get allTokenMorphsToConstructIdentifiers + Set get morphFeatureSetToPractice => + MorphFeaturesEnum.values.where((feature) { + // pos is always included + if (feature == MorphFeaturesEnum.Pos) { + return true; + } + return tokens?.any((token) => token.morph.containsKey(feature.name)) ?? + false; + }).toSet(); + + Set posSetToPractice(ActivityTypeEnum a) => + PartOfSpeechEnum.values.where((pos) { + // some pos are not eligible for practice at all + if (!pos.eligibleForPractice(a)) { + return false; + } + return tokens?.any( + (token) => token.pos.toLowerCase() == pos.name.toLowerCase(), + ) ?? + false; + }).toSet(); + + List tagsByFeature(MorphFeaturesEnum feature) { + return tokens + ?.where((t) => t.morph.containsKey(feature.name)) + .map((t) => t.morph[feature.name]) + .cast() + .toList() ?? + []; + } } diff --git a/lib/pangea/events/extensions/pangea_event_extension.dart b/lib/pangea/events/extensions/pangea_event_extension.dart index bceebb3c1..bad437e49 100644 --- a/lib/pangea/events/extensions/pangea_event_extension.dart +++ b/lib/pangea/events/extensions/pangea_event_extension.dart @@ -1,19 +1,17 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; extension PangeaEvent on Event { V getPangeaContent() { diff --git a/lib/pangea/events/models/pangea_token_model.dart b/lib/pangea/events/models/pangea_token_model.dart index 7ac8b341c..91674b54f 100644 --- a/lib/pangea/events/models/pangea_token_model.dart +++ b/lib/pangea/events/models/pangea_token_model.dart @@ -1,9 +1,6 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; - import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; @@ -13,10 +10,18 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; +import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_repo.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; + import '../../common/constants/model_keys.dart'; import '../../lemmas/lemma.dart'; @@ -153,44 +158,16 @@ class PangeaToken { /// alias for the end of the token ie offset + length int get end => text.offset + text.length; - bool get isContentWord => - ["NOUN", "VERB", "ADJ", "ADV"].contains(pos) && lemma.saveVocab; + bool get isContentWord => vocabConstructID.isContentWord; String get analyticsDebugPrint => - "content: ${text.content} isContentWord: $isContentWord total_xp:$xp vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning)}"; + "content: ${text.content} isContentWord: $isContentWord vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning, null)}"; bool get canBeDefined => - [ - "ADJ", - "ADP", - "ADV", - "AUX", - "CCONJ", - "DET", - "INTJ", - "NOUN", - "NUM", - "PRON", - "SCONJ", - "VERB", - ].contains(pos) && - lemma.saveVocab; + PartOfSpeechEnumExtensions.fromString(pos)?.canBeDefined ?? false; bool get canBeHeard => - [ - "ADJ", - "ADV", - "AUX", - "DET", - "INTJ", - "NOUN", - "NUM", - "PRON", - "PROPN", - "SCONJ", - "VERB", - ].contains(pos) && - lemma.saveVocab; + PartOfSpeechEnumExtensions.fromString(pos)?.canBeHeard ?? false; /// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma OneConstructUse toVocabUse( @@ -316,9 +293,10 @@ class PangeaToken { debugger(when: kDebugMode); return false; } - return morphConstruct(morphFeature, morphTag) - .uses - .any((u) => u.useType == a.correctUse && u.form == text.content); + return morphConstruct(morphFeature)?.uses.any( + (u) => u.useType == a.correctUse && u.form == text.content, + ) ?? + false; case ActivityTypeEnum.messageMeaning: debugger(when: kDebugMode); ErrorHandler.logError( @@ -332,49 +310,45 @@ class PangeaToken { bool _isActivityProbablyLevelAppropriate( ActivityTypeEnum a, [ String? morphFeature, - String? morphTag, ]) { switch (a) { case ActivityTypeEnum.wordMeaning: - final double contentModifier = isContentWord ? 0.5 : 1; - - // TODO: turn this back on, see how much it fires, and discuss - // debugPrint( - // "days since last eligible use for meaning: $_daysSinceLastEligibleUseForMeaning for ${text.content}", - // ); - - if (_daysSinceLastEligibleUseForMeaning < - 3 * vocabConstruct.points * contentModifier) { - return false; - } - - return true; + return vocabConstructID.isActivityProbablyLevelAppropriate( + a, + text.content, + ); case ActivityTypeEnum.wordFocusListening: - return !didActivitySuccessfully(a) || daysSinceLastUseByType(a) > 30; + return !didActivitySuccessfully(a) || + daysSinceLastUseByType(a, null) > 30; case ActivityTypeEnum.hiddenWordListening: - return daysSinceLastUseByType(a) > 7; + return daysSinceLastUseByType(a, null) > 7; case ActivityTypeEnum.lemmaId: return false; // disabling lemma activities for now - // It has 2 purposes:• learning value• triangulating our determination of the lemma with - // AI plus user verification.However, displaying the lemma during the meaning activity helps + // It has 2 purposes: + // • learning value + // • triangulating our determination of the lemma with AI plus user verification. + // However, displaying the lemma during the meaning activity helps // disambiguate what the meaning activity is about. This is probably more valuable than the // lemma activity itself. The piping for the lemma activity will stay there if we want to turn - //it back on, maybe in select instances. + // it back on, maybe in select instances. // return _didActivitySuccessfully(ActivityTypeEnum.wordMeaning) && // daysSinceLastUseByType(a) > 7; case ActivityTypeEnum.emoji: + return vocabConstructID.isActivityProbablyLevelAppropriate( + a, + text.content, + ); + case ActivityTypeEnum.messageMeaning: return true; case ActivityTypeEnum.morphId: - if (morphFeature == null || morphTag == null) { - debugger(when: kDebugMode); - return false; - } - // return _daysSinceLastUsedMorphForm(morphFeature, morphTag) > - // 2 * morphConstruct(morphFeature, morphTag).points; - return _daysSinceLastUsedMorphForm(morphFeature, morphTag) > 1; + return morphFeature != null + ? morphIdByFeature(morphFeature) + ?.isActivityProbablyLevelAppropriate(a, text.content) ?? + false + : false; } } @@ -401,17 +375,13 @@ class PangeaToken { required String? tag, }) { return isActivityBasicallyEligible(a, feature, tag) && - _isActivityProbablyLevelAppropriate(a, feature, tag); + _isActivityProbablyLevelAppropriate(a, feature); } ConstructUses get vocabConstruct => MatrixState.pangeaController.getAnalytics.constructListModel .getConstructUses( - ConstructIdentifier( - lemma: lemma.text, - type: ConstructTypeEnum.vocab, - category: pos, - ), + vocabConstructID, ) ?? ConstructUses( lemma: lemma.text, @@ -420,63 +390,32 @@ class PangeaToken { uses: [], ); - 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: [], - ); + ConstructUses? morphConstruct(String morphFeature) => + morphIdByFeature(morphFeature)?.constructUses; - int get xp { - return constructs.fold( - 0, - (previousValue, element) => previousValue + element.points, + ConstructIdentifier? morphIdByFeature(String feature) { + final tag = getMorphTag(feature); + if (tag == null) return null; + return ConstructIdentifier( + lemma: tag, + type: ConstructTypeEnum.morph, + category: feature, ); } - /// days since last eligible use for meaning - /// this is the number of days since the last time the user used this word - /// in a way that would engage with the meaning of the word - /// importantly, this excludes emoji activities - /// we want users to be able to do an emoji activity as a ramp up to - /// a word meaning activity - int get _daysSinceLastEligibleUseForMeaning { - final times = vocabConstruct.uses - .where( - (u) => - u.useType.sentByUser || - ActivityTypeEnum.wordMeaning.associatedUseTypes - .contains(u.useType) || - ActivityTypeEnum.messageMeaning.associatedUseTypes - .contains(u.useType), - ) - .map((u) => u.timeStamp) - .toList(); + /// lastUsed by activity type, construct and form + DateTime? _lastUsedByActivityType(ActivityTypeEnum a, String? feature) { + if (a == ActivityTypeEnum.morphId && feature == null) { + debugger(when: kDebugMode); + return null; + } + final ConstructIdentifier? cId = a == ActivityTypeEnum.morphId + ? morphIdByFeature(feature!) + : vocabConstructID; - if (times.isEmpty) return 1000; + if (cId == null) return null; - // return the most recent timestamp - final last = times.reduce((a, b) => a.isAfter(b) ? a : b); - - return DateTime.now().difference(last).inDays; - } - - /// lastUsed by activity type - DateTime? _lastUsedByActivityType(ActivityTypeEnum a) { - final List filteredConstructs = - constructs.where((c) => a.constructFilter(c.id)).toList(); - - final correctUseTimestamps = filteredConstructs - .expand((c) => c.uses) + final correctUseTimestamps = cId.constructUses.uses .where((u) => u.form == text.content) .map((u) => u.timeStamp) .toList(); @@ -488,26 +427,13 @@ class PangeaToken { } /// daysSinceLastUse by activity type - int daysSinceLastUseByType(ActivityTypeEnum a) { - final lastUsed = _lastUsedByActivityType(a); + /// returns 1000 if there is no last use + int daysSinceLastUseByType(ActivityTypeEnum a, String? feature) { + final lastUsed = _lastUsedByActivityType(a, feature); if (lastUsed == null) return 1000; return DateTime.now().difference(lastUsed).inDays; } - int _daysSinceLastUsedMorphForm(String morphFeature, String morphTag) { - final uses = morphConstruct(morphFeature, morphTag) - .uses - .where((u) => u.form == text.content) - .map((u) => u.timeStamp) - .toList(); - - if (uses.isEmpty) return 1000; - - final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b); - - return DateTime.now().difference(lastUsed).inDays; - } - List get _constructIDs { final List ids = []; ids.add( @@ -538,7 +464,18 @@ class PangeaToken { .cast() .toList(); - Future> getEmojiChoices() => vocabConstructID.getEmojiChoices(); + Future> getEmojiChoices() => LemmaInfoRepo.get( + LemmaInfoRequest( + 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); ConstructIdentifier get vocabConstructID => ConstructIdentifier( lemma: lemma.text, @@ -546,11 +483,14 @@ class PangeaToken { category: pos, ); - Future setEmoji(String emoji) => vocabConstructID.setEmoji(emoji); + /// [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(List emojis) => + vocabConstructID.setUserLemmaInfo(UserSetLemmaInfo(emojis: emojis)); /// [getEmoji] gets the emoji for the lemma /// NOTE: assumes that the language of the lemma is the same as the user's current l2 - String? getEmoji() => vocabConstructID.userSetEmoji; + List getEmoji() => vocabConstructID.userSetEmoji; String get xpEmoji => vocabConstruct.xpEmoji; @@ -582,9 +522,9 @@ class PangeaToken { /// initial default input mode for a token MessageMode get modeForToken { - if (getEmoji() == null) { - return MessageMode.wordEmoji; - } + // if (getEmoji() == null) { + // return MessageMode.wordEmoji; + // } if (shouldDoActivity( a: ActivityTypeEnum.wordMeaning, @@ -643,4 +583,62 @@ class PangeaToken { }); return morphEntries; } + + bool shouldDoActivityByMessageMode(MessageMode mode) { + // debugPrint("should do activity for ${text.content} in $mode"); + return mode.associatedActivityType != null + ? shouldDoActivity( + a: mode.associatedActivityType!, + feature: null, + tag: null, + ) + : false; + } + + List get allConstructIds => _constructIDs; + + List get morphConstructIds => morph.entries + .map( + (e) => ConstructIdentifier( + lemma: e.key, + type: ConstructTypeEnum.morph, + category: e.value, + ), + ) + .toList(); + + List get morphsBasicallyEligibleForPracticeByPriority => + MorphFeaturesEnumExtension.eligibleForPractice.where((f) { + return f == MorphFeaturesEnum.Pos || getMorphTag(f.name) != null; + }).map((f) { + if (f == MorphFeaturesEnum.Pos) { + return ConstructIdentifier( + lemma: pos, + type: ConstructTypeEnum.morph, + category: f.name, + ); + } + return ConstructIdentifier( + lemma: getMorphTag(f.name)!, + type: ConstructTypeEnum.morph, + category: f.name, + ); + }).toList(); + + bool hasMorph(ConstructIdentifier cId) { + if (cId.category == "pos") { + return morph["pos"].toString().toLowerCase() == cId.lemma.toLowerCase(); + } + return morph.entries.any( + (e) => + e.key.toLowerCase() == cId.lemma.toLowerCase() && + e.value.toString().toLowerCase() == cId.category.toLowerCase(), + ); + } + + /// [0,infinity) - a lower number means higher priority + int activityPriorityScore(ActivityTypeEnum a, String? morphFeature) { + return daysSinceLastUseByType(a, morphFeature) * + (vocabConstructID.isContentWord ? 1 : 2); + } } diff --git a/lib/pangea/events/models/pangea_token_text_model.dart b/lib/pangea/events/models/pangea_token_text_model.dart index ecfef47e1..7f7323cf6 100644 --- a/lib/pangea/events/models/pangea_token_text_model.dart +++ b/lib/pangea/events/models/pangea_token_text_model.dart @@ -42,4 +42,6 @@ class PangeaTokenText { @override int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode; + + String get uniqueKey => "$content-$offset-$length"; } diff --git a/lib/pangea/events/utils/message_text_util.dart b/lib/pangea/events/utils/message_text_util.dart index a230648ef..d4c98e853 100644 --- a/lib/pangea/events/utils/message_text_util.dart +++ b/lib/pangea/events/utils/message_text_util.dart @@ -1,16 +1,45 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart'; +import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart'; +import 'package:flutter/material.dart'; + +class TokenPosition { + /// Start index of the full substring in the message + final int start; + + /// End index of the full substring in the message + final int end; + + /// Start index of the token in the message + final int tokenStart; + + /// End index of the token in the message + final int tokenEnd; + + final bool selected; + final bool hideContent; + final PangeaToken? token; + final bool isHighlighted; + + const TokenPosition({ + required this.start, + required this.end, + required this.tokenStart, + required this.tokenEnd, + required this.hideContent, + required this.selected, + required this.isHighlighted, + this.token, + }); +} class MessageTextUtil { static List? getTokenPositions( PangeaMessageEvent pangeaMessageEvent, { MessageAnalyticsEntry? messageAnalyticsEntry, bool Function(PangeaToken)? isSelected, + bool Function(PangeaToken)? isHighlighted, }) { try { if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) { @@ -49,6 +78,7 @@ class MessageTextUtil { tokenEnd: startIndex, hideContent: false, selected: (isSelected?.call(token) ?? false) && !hasHiddenContent, + isHighlighted: isHighlighted?.call(token) ?? false, ), ); } @@ -91,6 +121,7 @@ class MessageTextUtil { selected: (isSelected?.call(token) ?? false) && !hideContent && !hasHiddenContent, + isHighlighted: isHighlighted?.call(token) ?? false, ), ); diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index 587b0c29e..9bc63da4a 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -20,12 +20,15 @@ enum InstructionsEnum { missingVoice, clickBestOption, completeActivitiesToUnlock, - lemmaMeaning, + chooseLemmaMeaning, activityPlannerOverview, ttsDisabled, chooseEmoji, + chooseWordAudio, + chooseMorphs, analyticsVocabList, morphAnalyticsList, + readingAssistanceOverview, } extension InstructionsEnumExtension on InstructionsEnum { @@ -43,6 +46,7 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.missingVoiceTitle; case InstructionsEnum.ttsDisabled: return l10n.ttsDisbledTitle; + case InstructionsEnum.chooseWordAudio: case InstructionsEnum.chooseEmoji: case InstructionsEnum.activityPlannerOverview: case InstructionsEnum.clickAgainToDeselect: @@ -51,9 +55,11 @@ extension InstructionsEnumExtension on InstructionsEnum { case InstructionsEnum.translationChoices: case InstructionsEnum.clickBestOption: case InstructionsEnum.completeActivitiesToUnlock: - case InstructionsEnum.lemmaMeaning: + case InstructionsEnum.chooseLemmaMeaning: + case InstructionsEnum.chooseMorphs: case InstructionsEnum.analyticsVocabList: case InstructionsEnum.morphAnalyticsList: + case InstructionsEnum.readingAssistanceOverview: ErrorHandler.logError( e: Exception("No title for this instruction"), m: 'InstructionsEnumExtension.title', @@ -66,6 +72,21 @@ extension InstructionsEnumExtension on InstructionsEnum { } } + // IconData? get icon { + // switch (this) { + // case InstructionsEnum.itInstructions: + // return Icons.translate; + // case InstructionsEnum.clickMessage: + // return Icons.touch_app; + // case InstructionsEnum.blurMeansTranslate: + // return Icons.blur_on; + // case InstructionsEnum.tooltipInstructions: + // return Icons.help; + // case InstructionsEnum.missingVoice: + // return Icons.mic_off; + // } + // } + String body(L10n l10n) { switch (this) { case InstructionsEnum.itInstructions: @@ -92,18 +113,24 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.clickBestOption; case InstructionsEnum.completeActivitiesToUnlock: return l10n.completeActivitiesToUnlock; - case InstructionsEnum.lemmaMeaning: - return l10n.lemmaMeaningInstructionsBody; + case InstructionsEnum.chooseLemmaMeaning: + return l10n.chooseLemmaMeaningInstructionsBody; case InstructionsEnum.activityPlannerOverview: return l10n.activityPlannerOverviewInstructionsBody; case InstructionsEnum.chooseEmoji: return l10n.chooseEmojiInstructionsBody; case InstructionsEnum.ttsDisabled: return l10n.ttsDisabledBody; + case InstructionsEnum.chooseWordAudio: + return l10n.chooseWordAudioInstructionsBody; + case InstructionsEnum.chooseMorphs: + return l10n.chooseMorphsInstructionsBody; case InstructionsEnum.analyticsVocabList: return l10n.analyticsVocabListBody; case InstructionsEnum.morphAnalyticsList: return l10n.morphAnalyticsListBody; + case InstructionsEnum.readingAssistanceOverview: + return l10n.readingAssistanceOverviewBody; } } diff --git a/lib/pangea/instructions/instructions_inline_tooltip.dart b/lib/pangea/instructions/instructions_inline_tooltip.dart index 7943df3d3..032044daa 100644 --- a/lib/pangea/instructions/instructions_inline_tooltip.dart +++ b/lib/pangea/instructions/instructions_inline_tooltip.dart @@ -1,17 +1,17 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class InstructionsInlineTooltip extends StatefulWidget { final InstructionsEnum instructionsEnum; + final bool bold; const InstructionsInlineTooltip({ super.key, required this.instructionsEnum, + this.bold = false, }); @override @@ -20,14 +20,27 @@ class InstructionsInlineTooltip extends StatefulWidget { } class InstructionsInlineTooltipState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { bool _isToggledOff = true; late AnimationController _controller; late Animation _animation; + @override + void didUpdateWidget(covariant InstructionsInlineTooltip oldWidget) { + debugPrint("InstructionsInlineTooltip didUpdateWidget"); + if (oldWidget.instructionsEnum != widget.instructionsEnum) { + setToggled(); + } + super.didUpdateWidget(oldWidget); + } + @override void initState() { super.initState(); + setToggled(); + } + + void setToggled() { _isToggledOff = widget.instructionsEnum.isToggledOff; // Initialize AnimationController and Animation @@ -43,6 +56,8 @@ class InstructionsInlineTooltipState extends State // Start in correct state if (!_isToggledOff) _controller.forward(); + + setState(() {}); } @override @@ -69,7 +84,7 @@ class InstructionsInlineTooltipState extends State child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppConfig.borderRadius), - color: Theme.of(context).colorScheme.primary.withAlpha(5), + color: AppConfig.gold.withAlpha(widget.bold ? 80 : 10), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), @@ -87,7 +102,8 @@ class InstructionsInlineTooltipState extends State child: Center( child: Text( widget.instructionsEnum.body(L10n.of(context)), - style: Theme.of(context).textTheme.bodyLarge, + style: Theme.of(context).textTheme.titleLarge ?? + Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ), ), diff --git a/lib/pangea/instructions/reset_instructions_list_tile.dart b/lib/pangea/instructions/reset_instructions_list_tile.dart new file mode 100644 index 000000000..a6cdf0b11 --- /dev/null +++ b/lib/pangea/instructions/reset_instructions_list_tile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; + +class ResetInstructionsListTile extends StatelessWidget { + const ResetInstructionsListTile({ + super.key, + required this.controller, + }); + + final SettingsLearningController controller; + + @override + Widget build(BuildContext context) { + //TODO: add to L10n + return ListTile( + leading: const Icon(Icons.lightbulb), + title: const Text( + "Reset instruction tooltips", + ), + subtitle: const Text( + "Click to show instruction tooltips like for a brand new user.", + ), + onTap: () => showAdaptiveDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text( + "Reset instruction tooltips?", + ), + actions: [ + TextButton( + onPressed: () { + controller.resetInstructionTooltips(); + Navigator.of(context).pop(); + }, + child: const Text("Reset"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("Cancel"), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/pangea/learning_settings/pages/settings_learning.dart b/lib/pangea/learning_settings/pages/settings_learning.dart index 719b99381..6cc977371 100644 --- a/lib/pangea/learning_settings/pages/settings_learning.dart +++ b/lib/pangea/learning_settings/pages/settings_learning.dart @@ -4,6 +4,7 @@ import 'package:country_picker/country_picker.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/instructions/instruction_settings.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning_view.dart'; @@ -118,6 +119,20 @@ class SettingsLearningController extends State { } } + Future resetInstructionTooltips() async { + await showFutureLoadingDialog( + context: context, + future: () async => pangeaController.userController.updateProfile( + (profile) { + profile.instructionSettings = InstructionSettings(); + return profile; + }, + waitForDataInSync: true, + ), + ); + if (mounted) setState(() {}); + } + Future setSelectedLanguage({ LanguageModel? sourceLanguage, LanguageModel? targetLanguage, diff --git a/lib/pangea/learning_settings/pages/settings_learning_view.dart b/lib/pangea/learning_settings/pages/settings_learning_view.dart index f596865eb..cd8eb6a30 100644 --- a/lib/pangea/learning_settings/pages/settings_learning_view.dart +++ b/lib/pangea/learning_settings/pages/settings_learning_view.dart @@ -1,16 +1,11 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:app_settings/app_settings.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; +import 'package:fluffychat/pangea/instructions/reset_instructions_list_tile.dart'; import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart'; import 'package:fluffychat/pangea/learning_settings/widgets/country_picker_tile.dart'; @@ -19,6 +14,10 @@ import 'package:fluffychat/pangea/learning_settings/widgets/p_settings_switch_li import 'package:fluffychat/pangea/spaces/models/space_model.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.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:url_launcher/url_launcher_string.dart'; class SettingsLearningView extends StatelessWidget { final SettingsLearningController controller; @@ -255,6 +254,7 @@ class SettingsLearningView extends StatelessWidget { activeColor: AppConfig.activeToggleColor, contentPadding: EdgeInsets.zero, ), + ResetInstructionsListTile(controller: controller), ], ), ), diff --git a/lib/pangea/lemmas/lemma_emoji_row.dart b/lib/pangea/lemmas/lemma_emoji_row.dart new file mode 100644 index 000000000..c7e928088 --- /dev/null +++ b/lib/pangea/lemmas/lemma_emoji_row.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart'; + +class LemmaEmojiRow extends StatelessWidget { + final ConstructIdentifier cId; + final VoidCallback onTap; + final bool isSelected; + + /// if a setState is defined then we're in a context where + /// we allow removing an emoji + /// later we'll probably want to allow this everywhere + final void Function()? removeCallback; + + const LemmaEmojiRow({ + required this.cId, + required this.onTap, + required this.removeCallback, + this.isSelected = false, + super.key, + }); + + List get emojis => cId.userSetEmoji; + + Future onEmojiTap(String toRemove) async { + await cId.setUserLemmaInfo( + UserSetLemmaInfo( + emojis: emojis.where((e) => e != toRemove).toList(), + ), + ); + removeCallback!(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (var i = 0; i < maxEmojisPerLemma; i++) + Container( + height: 40, + width: 40, + alignment: Alignment.center, + child: i < emojis.length + ? GestureDetector( + onTap: removeCallback == null + ? null + : () => onEmojiTap(emojis[i]), + child: Text( + emojis[i], + style: Theme.of(context).textTheme.bodyLarge, + ), + ) + : WordZoomActivityButton( + icon: Icon( + Icons.add_reaction_outlined, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + isSelected: isSelected, + onPressed: onTap, + opacity: isSelected ? 1 : 0.4, + tooltip: MessageMode.wordEmoji.title(context), + ), + ), + ], + ); + } +} diff --git a/lib/pangea/lemmas/lemma_info_repo.dart b/lib/pangea/lemmas/lemma_info_repo.dart index 3fe4d1cf0..8490462d0 100644 --- a/lib/pangea/lemmas/lemma_info_repo.dart +++ b/lib/pangea/lemmas/lemma_info_repo.dart @@ -1,15 +1,18 @@ import 'dart:convert'; - -import 'package:get_storage/get_storage.dart'; -import 'package:http/http.dart'; +import 'dart:developer'; import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; import 'package:fluffychat/pangea/common/network/urls.dart'; -import 'package:fluffychat/pangea/events/models/content_feedback.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; +import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; class LemmaInfoRepo { static final GetStorage _lemmaStorage = GetStorage('lemma_storage'); @@ -20,38 +23,22 @@ class LemmaInfoRepo { _lemmaStorage.write(request.storageKey, response.toJson()); } - static Future get( - LemmaInfoRequest request, [ - String? feedback, - bool useExpireAt = false, - ]) async { + static Future _fetch(LemmaInfoRequest request) async { final cachedJson = _lemmaStorage.read(request.storageKey); final cached = cachedJson == null ? null : LemmaInfoResponse.fromJson(cachedJson); if (cached != null) { - if (feedback == null) { - // at this point we have a cache without feedback - if (!useExpireAt) { - // return cache as is if we're not using expireAt - return cached; - } else if (cached.expireAt != null) { - if (DateTime.now().isBefore(cached.expireAt!)) { - // return cache as is if we're using expireAt and it's set but not expired - return cached; - } - } - // we intentionally do not handle the case of expired at not set because - // old caches won't have them set, and we want to trigger a new - // choreo call + if (DateTime.now().isBefore(cached.expireAt!)) { + // return cache as is if we're using expireAt and it's set but not expired + // debugPrint( + // 'using cached data for ${request.lemma} ${cached.toJson()}', + // ); + return cached; } else { - // we're adding this within the service to avoid needing to have the widgets - // save state including the bad response - request.feedback = ContentFeedback( - cached, - feedback, - ); + // if it's expired, remove it + _lemmaStorage.remove(request.storageKey); } } @@ -70,6 +57,47 @@ class LemmaInfoRepo { set(request, response); + // debugPrint( + // 'fetched data for ${request.lemma} ${response.toJson()}', + // ); + return response; } + + /// Get lemma info, prefering user set data over fetched data + static Future get(LemmaInfoRequest request) async { + try { + // if the user has either emojis or meaning in the past, use those first + final UserSetLemmaInfo? userSetLemmaInfo = request.cId.userLemmaInfo; + + final List emojis = userSetLemmaInfo?.emojis ?? []; + String? meaning = userSetLemmaInfo?.meaning; + + // if the user has not set these, fetch from the server + if (emojis.length < maxEmojisPerLemma || meaning == null) { + final LemmaInfoResponse fetched = await _fetch(request); + + while (emojis.length < maxEmojisPerLemma && fetched.emoji.isNotEmpty) { + final maybeToAdd = fetched.emoji.removeAt(0); + if (!emojis.contains(maybeToAdd)) { + emojis.add(maybeToAdd); + } + } + meaning ??= fetched.meaning; + } else { + // debugPrint( + // 'using user set data for ${request.lemma} ${userSetLemmaInfo?.toJson()}', + // ); + } + + return LemmaInfoResponse( + emoji: emojis, + meaning: meaning, + ); + } catch (e) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, data: request.toJson()); + rethrow; + } + } } diff --git a/lib/pangea/lemmas/lemma_info_request.dart b/lib/pangea/lemmas/lemma_info_request.dart index e973549fd..b82ee077b 100644 --- a/lib/pangea/lemmas/lemma_info_request.dart +++ b/lib/pangea/lemmas/lemma_info_request.dart @@ -1,3 +1,5 @@ +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/content_feedback.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; @@ -44,4 +46,10 @@ class LemmaInfoRequest { String get storageKey { return 'l:$lemma,p:$partOfSpeech,lang:$lemmaLang,l1:$userL1'; } + + ConstructIdentifier get cId => ConstructIdentifier( + lemma: lemma, + type: ConstructTypeEnum.vocab, + category: partOfSpeech, + ); } diff --git a/lib/pangea/lemmas/lemma_info_response.dart b/lib/pangea/lemmas/lemma_info_response.dart index 748eb85c9..05840ce2c 100644 --- a/lib/pangea/lemmas/lemma_info_response.dart +++ b/lib/pangea/lemmas/lemma_info_response.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/pangea/events/models/content_feedback.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; class LemmaInfoResponse implements JsonSerializable { final List emoji; @@ -13,7 +14,12 @@ class LemmaInfoResponse implements JsonSerializable { factory LemmaInfoResponse.fromJson(Map json) { return LemmaInfoResponse( - emoji: (json['emoji'] as List).map((e) => e as String).toList(), + // NOTE: This is a workaround for the fact that the server sometimes sends more than 3 emojis + emoji: (json['emoji'] as List) + .map((e) => e as String) + .toList() + .take(maxEmojisPerLemma) + .toList(), meaning: json['meaning'] as String, expireAt: json['expireAt'] == null ? null diff --git a/lib/pangea/lemmas/user_set_lemma_info.dart b/lib/pangea/lemmas/user_set_lemma_info.dart new file mode 100644 index 000000000..0394329c9 --- /dev/null +++ b/lib/pangea/lemmas/user_set_lemma_info.dart @@ -0,0 +1,34 @@ +class UserSetLemmaInfo { + final String? meaning; + final List? emojis; + + UserSetLemmaInfo({ + this.emojis, + this.meaning, + }); + + factory UserSetLemmaInfo.fromJson(Map json) { + return UserSetLemmaInfo( + emojis: json["emojis"] != null ? List.from(json["emojis"]) : null, + meaning: json['meaning'] as String?, + ); + } + + Map toJson() { + return { + 'emojis': emojis, + 'meaning': meaning, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserSetLemmaInfo && + runtimeType == other.runtimeType && + emojis == other.emojis && + meaning == other.meaning; + + @override + int get hashCode => emojis.hashCode ^ meaning.hashCode; +} diff --git a/lib/pangea/message_token_text/hidden_text.dart b/lib/pangea/message_token_text/hidden_text.dart new file mode 100644 index 000000000..7ac6415e3 --- /dev/null +++ b/lib/pangea/message_token_text/hidden_text.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class HiddenText extends StatelessWidget { + final String text; + final TextStyle style; + + const HiddenText({ + super.key, + required this.text, + required this.style, + }); + + @override + Widget build(BuildContext context) { + final TextPainter textPainter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + + final textWidth = textPainter.size.width; + final textHeight = textPainter.size.height; + + textPainter.dispose(); + + return SizedBox( + height: textHeight, + child: Stack( + children: [ + Container( + width: textWidth, + height: textHeight, + color: Colors.transparent, + ), + Positioned( + bottom: 0, + child: Container( + width: textWidth, + height: 1, + color: style.color, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/message_token_text/message_token_button.dart b/lib/pangea/message_token_text/message_token_button.dart new file mode 100644 index 000000000..307058197 --- /dev/null +++ b/lib/pangea/message_token_text/message_token_button.dart @@ -0,0 +1,241 @@ +import 'dart:developer'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constructs/construct_form.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +const double tokenButtonHeight = 40.0; +const double tokenButtonDefaultFontSize = 10; +const int maxEmojisPerLemma = 1; +const double estimatedEmojiWidthRatio = 2; +const double estimatedEmojiHeightRatio = 1.3; + +class MessageTokenButton extends StatefulWidget { + final MessageOverlayController? overlayController; + final PangeaToken token; + final TextStyle textStyle; + final double width; + final bool animate; + final TargetTokensAndActivityType? activity; + + const MessageTokenButton({ + super.key, + required this.overlayController, + required this.token, + required this.textStyle, + required this.width, + required this.activity, + this.animate = false, + }); + + @override + MessageTokenButtonState createState() => MessageTokenButtonState(); +} + +class MessageTokenButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _heightAnimation; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration( + milliseconds: AppConfig.overlayAnimationDuration, + // seconds: 5, + ), + ); + + _heightAnimation = Tween( + begin: 0, + end: tokenButtonHeight, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + if (widget.animate) { + _controller.forward(); + } + } + + double get topPadding => 10.0; + + double get height => + widget.animate ? _heightAnimation.value : tokenButtonHeight; + + @override + void didUpdateWidget(covariant MessageTokenButton oldWidget) { + if (oldWidget.overlayController?.toolbarMode != + widget.overlayController?.toolbarMode || + oldWidget.overlayController?.selectedToken != + widget.overlayController?.selectedToken || + oldWidget.overlayController?.selectedMorph != + widget.overlayController?.selectedMorph || + oldWidget.activity != widget.activity) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + double get textSize => + widget.textStyle.fontSize ?? tokenButtonDefaultFontSize; + + double get emojiSize => textSize * estimatedEmojiWidthRatio; + + TextStyle get emojiStyle => widget.textStyle.copyWith( + fontSize: textSize + 4, + ); + + TargetTokensAndActivityType? get activity => widget.activity; + + Widget get emojiView { + // if (widget.token.text.content.length == 1 || maxEmojisPerLemma == 1) { + return ShrinkableText( + text: widget.token.vocabConstructID.userSetEmoji.firstOrNull ?? '', + maxWidth: widget.width, + style: emojiStyle, + ); + // } + // return Stack( + // alignment: Alignment.center, + // children: widget.token.vocabConstructID.userSetEmoji + // .take(maxEmojisPerLemma) + // .mapIndexed( + // (index, emoji) => Positioned( + // left: min( + // index / + // widget.token.vocabConstructID.userSetEmoji.length * + // totalAvailableWidth, + // index * emojiSize, + // ), + // child: Text( + // emoji, + // style: emojiStyle, + // ), + // ), + // ) + // .toList() + // .reversed + // .toList(), + // ); + } + + Widget get content { + final tokenActivity = activity; + if (tokenActivity == null) { + if (MessageMode.wordEmoji == widget.overlayController?.toolbarMode) { + return SizedBox(height: height, child: emojiView); + } + return SizedBox(height: height); + } + + if (MessageMode.wordMorph == widget.overlayController?.toolbarMode) { + if (activity?.morphFeature == null) { + debugger(when: kDebugMode); + return SizedBox(height: height); + } + return InkWell( + onHover: (isHovered) => setState(() => _isHovered = isHovered), + onTap: () => widget.overlayController! + .onMorphActivitySelect(widget.token, activity!.morphFeature!), + borderRadius: borderRadius, + child: Container( + height: height, + width: min(widget.width, height), + alignment: Alignment.center, + child: Opacity( + opacity: (widget.overlayController?.selectedToken == widget.token && + widget.overlayController?.selectedMorph == + activity?.morphFeature) || + _isHovered + ? 1.0 + : 0.5, + child: Icon( + Symbols.toys_and_games, + color: Theme.of(context).colorScheme.primary, + ), + // MorphIcon(morphFeature: activity!.morphFeature!, morphTag: null), + ), + ), + ); + } + + return DragTarget( + builder: (BuildContext context, accepted, rejected) { + final double colorAlpha = 0.3 + + (widget.overlayController?.selectedChoice != null ? 0.3 : 0.0); + + return InkWell( + onHover: (isHovered) => setState(() => _isHovered = isHovered), + onTap: widget.overlayController?.selectedChoice != null + ? () => widget.overlayController!.onMatchAttempt( + widget.token, + widget.overlayController!.selectedChoice!, + ) + : null, + borderRadius: borderRadius, + child: Container( + height: height, + padding: EdgeInsets.only(top: topPadding), + width: + MessageMode.wordMeaning == widget.overlayController?.toolbarMode + ? widget.width + : min(widget.width, height), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withAlpha((colorAlpha * 255).toInt()), + borderRadius: borderRadius, + border: accepted.isNotEmpty || + (widget.overlayController?.selectedChoice != null && + _isHovered) + ? Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ) + : null, + ), + ), + ); + }, + onAcceptWithDetails: (details) => + widget.overlayController!.onMatchAttempt(widget.token, details.data), + ); + } + + static final borderRadius = BorderRadius.circular(AppConfig.borderRadius - 4); + + @override + Widget build(BuildContext context) { + if (widget.overlayController == null) { + return const SizedBox.shrink(); + } + + if (!widget.animate) { + return content; + } + + return AnimatedBuilder( + animation: _heightAnimation, + builder: (context, child) => content, + ); + } +} diff --git a/lib/pangea/message_token_text/token_position_model.dart b/lib/pangea/message_token_text/token_position_model.dart new file mode 100644 index 000000000..a01434078 --- /dev/null +++ b/lib/pangea/message_token_text/token_position_model.dart @@ -0,0 +1,29 @@ +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; + +class TokenPositionModel { + /// Start index of the full substring in the message + final int start; + + /// End index of the full substring in the message + final int end; + + /// Start index of the token in the message + final int tokenStart; + + /// End index of the token in the message + final int tokenEnd; + + final bool selected; + final bool hideContent; + final PangeaToken? token; + + const TokenPositionModel({ + required this.start, + required this.end, + required this.tokenStart, + required this.tokenEnd, + required this.hideContent, + required this.selected, + this.token, + }); +} diff --git a/lib/pangea/morphs/get_icon_for_morph_feature.dart b/lib/pangea/morphs/get_icon_for_morph_feature.dart deleted file mode 100644 index 7c5f43164..000000000 --- a/lib/pangea/morphs/get_icon_for_morph_feature.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:material_symbols_icons/symbols.dart'; - -// 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; - case 'polarity': - return Icons.swap_vert; - case 'definite': - return Icons.check_circle_outline; - case 'prepcase': - return Icons.location_on_outlined; - case 'conjtype': - return Icons.compare_arrows; - default: - return Icons.help_outline; - } -} diff --git a/lib/pangea/morphs/morph_categories_enum.dart b/lib/pangea/morphs/morph_categories_enum.dart deleted file mode 100644 index 2207753a4..000000000 --- a/lib/pangea/morphs/morph_categories_enum.dart +++ /dev/null @@ -1,140 +0,0 @@ -// ignore_for_file: constant_identifier_names - -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; - -enum MorphologicalCategories { - Pos, - AdvType, - Aspect, - Case, - ConjType, - Definite, - Degree, - Evident, - Foreign, - Gender, - Mood, - NounType, - NumForm, - NumType, - Number, - NumberPsor, - Person, - Polarity, - Polite, - Poss, - PrepCase, - PronType, - PunctSide, - PunctType, - Reflex, - Tense, - VerbForm, - VerbType, - Voice, -} - -extension MorphologicalCategoriesExtension on MorphologicalCategories { - /// Convert enum to string - String toShortString() { - return toString().split('.').last.toLowerCase(); - } - - /// Convert string to enum - static MorphologicalCategories? fromString(String category) { - final morph = MorphologicalCategories.values.firstWhereOrNull( - (e) => - e.toShortString() == - category.toLowerCase().replaceAll(RegExp(r'[,\[\]]'), ''), - ); - if (morph == null) { - ErrorHandler.logError( - e: "Missing morphological category", - s: StackTrace.current, - data: {"category": category}, - ); - } - return morph; - } - - String getDisplayCopy(BuildContext context) { - switch (this) { - case MorphologicalCategories.Pos: - return L10n.of(context).grammarCopyPOS; - case MorphologicalCategories.AdvType: - return L10n.of(context).grammarCopyADVTYPE; - case MorphologicalCategories.Aspect: - return L10n.of(context).grammarCopyASPECT; - case MorphologicalCategories.Case: - return L10n.of(context).grammarCopyCASE; - case MorphologicalCategories.ConjType: - return L10n.of(context).grammarCopyCONJTYPE; - case MorphologicalCategories.Definite: - return L10n.of(context).grammarCopyDEFINITE; - case MorphologicalCategories.Degree: - return L10n.of(context).grammarCopyDEGREE; - case MorphologicalCategories.Evident: - return L10n.of(context).grammarCopyEVIDENT; - case MorphologicalCategories.Foreign: - return L10n.of(context).grammarCopyFOREIGN; - case MorphologicalCategories.Gender: - return L10n.of(context).grammarCopyGENDER; - case MorphologicalCategories.Mood: - return L10n.of(context).grammarCopyMOOD; - case MorphologicalCategories.NounType: - return L10n.of(context).grammarCopyNOUNTYPE; - case MorphologicalCategories.NumForm: - return L10n.of(context).grammarCopyNUMFORM; - case MorphologicalCategories.NumType: - return L10n.of(context).grammarCopyNUMTYPE; - case MorphologicalCategories.Number: - return L10n.of(context).grammarCopyNUMBER; - case MorphologicalCategories.NumberPsor: - return L10n.of(context).grammarCopyNUMBERPSOR; - case MorphologicalCategories.Person: - return L10n.of(context).grammarCopyPERSON; - case MorphologicalCategories.Polarity: - return L10n.of(context).grammarCopyPOLARITY; - case MorphologicalCategories.Polite: - return L10n.of(context).grammarCopyPOLITE; - case MorphologicalCategories.Poss: - return L10n.of(context).grammarCopyPOSS; - case MorphologicalCategories.PrepCase: - return L10n.of(context).grammarCopyPREPCASE; - case MorphologicalCategories.PronType: - return L10n.of(context).grammarCopyPRONTYPE; - case MorphologicalCategories.PunctSide: - return L10n.of(context).grammarCopyPUNCTSIDE; - case MorphologicalCategories.PunctType: - return L10n.of(context).grammarCopyPUNCTTYPE; - case MorphologicalCategories.Reflex: - return L10n.of(context).grammarCopyREFLEX; - case MorphologicalCategories.Tense: - return L10n.of(context).grammarCopyTENSE; - case MorphologicalCategories.VerbForm: - return L10n.of(context).grammarCopyVERBFORM; - case MorphologicalCategories.VerbType: - return L10n.of(context).grammarCopyVERBTYPE; - case MorphologicalCategories.Voice: - return L10n.of(context).grammarCopyVOICE; - } - } -} - -String? getMorphologicalCategoryCopy( - String categoryName, - BuildContext context, -) { - final MorphologicalCategories? category = - MorphologicalCategoriesExtension.fromString(categoryName); - - if (category == null) { - return null; - } - return category.getDisplayCopy(context); -} diff --git a/lib/pangea/morphs/morph_feature_display.dart b/lib/pangea/morphs/morph_feature_display.dart index 7c4debf31..4d5e12042 100644 --- a/lib/pangea/morphs/morph_feature_display.dart +++ b/lib/pangea/morphs/morph_feature_display.dart @@ -1,18 +1,14 @@ +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; -import 'package:fluffychat/pangea/morphs/morph_icon.dart'; - class MorphFeatureDisplay extends StatelessWidget { - const MorphFeatureDisplay({ + MorphFeatureDisplay({ super.key, required String morphFeature, - required String morphTag, - }) : _morphFeature = morphFeature, - _morphTag = morphTag; + }) : _morphFeature = MorphFeaturesEnumExtension.fromString(morphFeature); - final String _morphFeature; - final String _morphTag; + final MorphFeaturesEnum _morphFeature; @override Widget build(BuildContext context) { @@ -20,22 +16,17 @@ class MorphFeatureDisplay extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( - width: 32.0, - height: 32.0, + width: 24.0, + height: 24.0, child: MorphIcon( morphFeature: _morphFeature, - morphTag: _morphTag, + morphTag: null, ), ), const SizedBox(width: 10.0), Text( - getGrammarCopy( - category: _morphFeature, - lemma: _morphTag, - context: context, - ) ?? - _morphTag, - style: Theme.of(context).textTheme.titleLarge, + _morphFeature.getDisplayCopy(context), + style: Theme.of(context).textTheme.titleMedium, ), ], ); diff --git a/lib/pangea/morphs/morph_features_enum.dart b/lib/pangea/morphs/morph_features_enum.dart new file mode 100644 index 000000000..4fd2d9707 --- /dev/null +++ b/lib/pangea/morphs/morph_features_enum.dart @@ -0,0 +1,224 @@ +// ignore_for_file: constant_identifier_names + +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum MorphFeaturesEnum { + Pos, + AdvType, + Aspect, + Case, + ConjType, + Definite, + Degree, + Evident, + Foreign, + Gender, + Mood, + NounType, + NumForm, + NumType, + Number, + NumberPsor, + Person, + Polarity, + Polite, + Poss, + PrepCase, + PronType, + PunctSide, + PunctType, + Reflex, + Tense, + VerbForm, + VerbType, + Voice, + Unknown, +} + +extension MorphFeaturesEnumExtension on MorphFeaturesEnum { + /// Convert enum to string + String toShortString() { + return toString().split('.').last.toLowerCase(); + } + + /// Convert string to enum + static MorphFeaturesEnum fromString(String category) { + final morph = MorphFeaturesEnum.values.firstWhereOrNull( + (e) => + e.toShortString() == + category.toLowerCase().replaceAll(RegExp(r'[,\[\]]'), ''), + ); + if (morph == null) { + ErrorHandler.logError( + e: "Missing morphological category", + s: StackTrace.current, + data: {"category": category}, + ); + return MorphFeaturesEnum.Unknown; + } + return morph; + } + + String getDisplayCopy(BuildContext context) { + switch (this) { + case MorphFeaturesEnum.Pos: + return L10n.of(context).grammarCopyPOS; + case MorphFeaturesEnum.AdvType: + return L10n.of(context).grammarCopyADVTYPE; + case MorphFeaturesEnum.Aspect: + return L10n.of(context).grammarCopyASPECT; + case MorphFeaturesEnum.Case: + return L10n.of(context).grammarCopyCASE; + case MorphFeaturesEnum.ConjType: + return L10n.of(context).grammarCopyCONJTYPE; + case MorphFeaturesEnum.Definite: + return L10n.of(context).grammarCopyDEFINITE; + case MorphFeaturesEnum.Degree: + return L10n.of(context).grammarCopyDEGREE; + case MorphFeaturesEnum.Evident: + return L10n.of(context).grammarCopyEVIDENT; + case MorphFeaturesEnum.Foreign: + return L10n.of(context).grammarCopyFOREIGN; + case MorphFeaturesEnum.Gender: + return L10n.of(context).grammarCopyGENDER; + case MorphFeaturesEnum.Mood: + return L10n.of(context).grammarCopyMOOD; + case MorphFeaturesEnum.NounType: + return L10n.of(context).grammarCopyNOUNTYPE; + case MorphFeaturesEnum.NumForm: + return L10n.of(context).grammarCopyNUMFORM; + case MorphFeaturesEnum.NumType: + return L10n.of(context).grammarCopyNUMTYPE; + case MorphFeaturesEnum.Number: + return L10n.of(context).grammarCopyNUMBER; + case MorphFeaturesEnum.NumberPsor: + return L10n.of(context).grammarCopyNUMBERPSOR; + case MorphFeaturesEnum.Person: + return L10n.of(context).grammarCopyPERSON; + case MorphFeaturesEnum.Polarity: + return L10n.of(context).grammarCopyPOLARITY; + case MorphFeaturesEnum.Polite: + return L10n.of(context).grammarCopyPOLITE; + case MorphFeaturesEnum.Poss: + return L10n.of(context).grammarCopyPOSS; + case MorphFeaturesEnum.PrepCase: + return L10n.of(context).grammarCopyPREPCASE; + case MorphFeaturesEnum.PronType: + return L10n.of(context).grammarCopyPRONTYPE; + case MorphFeaturesEnum.PunctSide: + return L10n.of(context).grammarCopyPUNCTSIDE; + case MorphFeaturesEnum.PunctType: + return L10n.of(context).grammarCopyPUNCTTYPE; + case MorphFeaturesEnum.Reflex: + return L10n.of(context).grammarCopyREFLEX; + case MorphFeaturesEnum.Tense: + return L10n.of(context).grammarCopyTENSE; + case MorphFeaturesEnum.VerbForm: + return L10n.of(context).grammarCopyVERBFORM; + case MorphFeaturesEnum.VerbType: + return L10n.of(context).grammarCopyVERBTYPE; + case MorphFeaturesEnum.Voice: + return L10n.of(context).grammarCopyVOICE; + case MorphFeaturesEnum.Unknown: + return L10n.of(context).grammarCopyUNKNOWN; + } + } + + /// the subset of morphological categories that are important to practice for learning the language + /// by order of importance + static List get eligibleForPractice => [ + MorphFeaturesEnum.Pos, + MorphFeaturesEnum.Tense, + MorphFeaturesEnum.VerbForm, + MorphFeaturesEnum.VerbType, + MorphFeaturesEnum.Voice, + MorphFeaturesEnum.AdvType, + MorphFeaturesEnum.Aspect, + MorphFeaturesEnum.Case, + MorphFeaturesEnum.ConjType, + MorphFeaturesEnum.Definite, + MorphFeaturesEnum.Degree, + MorphFeaturesEnum.Evident, + MorphFeaturesEnum.Gender, + MorphFeaturesEnum.Mood, + MorphFeaturesEnum.NounType, + MorphFeaturesEnum.NumForm, + MorphFeaturesEnum.NumType, + MorphFeaturesEnum.Number, + MorphFeaturesEnum.NumberPsor, + MorphFeaturesEnum.Person, + MorphFeaturesEnum.Polarity, + MorphFeaturesEnum.Polite, + MorphFeaturesEnum.Poss, + MorphFeaturesEnum.PrepCase, + MorphFeaturesEnum.PronType, + MorphFeaturesEnum.Reflex, + ]; + + bool get isEligibleForPractice { + return eligibleForPractice.contains(this); + } + + IconData get fallbackIcon { + switch (this) { + case MorphFeaturesEnum.Number: + // google material 123 icon + return Icons.format_list_numbered; + case MorphFeaturesEnum.Gender: + return Icons.wc; + case MorphFeaturesEnum.Tense: + return Icons.access_time; + case MorphFeaturesEnum.Mood: + return Icons.mood; + case MorphFeaturesEnum.Person: + return Icons.person; + case MorphFeaturesEnum.Case: + return Icons.format_list_bulleted; + case MorphFeaturesEnum.Degree: + return Icons.trending_up; + case MorphFeaturesEnum.VerbForm: + return Icons.text_format; + case MorphFeaturesEnum.Voice: + return Icons.record_voice_over; + case MorphFeaturesEnum.Aspect: + return Icons.aspect_ratio; + case MorphFeaturesEnum.PronType: + return Icons.text_fields; + case MorphFeaturesEnum.NumType: + return Icons.format_list_numbered; + case MorphFeaturesEnum.Poss: + return Icons.account_balance; + case MorphFeaturesEnum.Reflex: + return Icons.refresh; + case MorphFeaturesEnum.Foreign: + return Icons.language; + case MorphFeaturesEnum.NounType: + return Symbols.abc; + case MorphFeaturesEnum.Pos: + return Symbols.toys_and_games; + case MorphFeaturesEnum.Polarity: + return Icons.swap_vert; + case MorphFeaturesEnum.Definite: + return Icons.check_circle_outline; + case MorphFeaturesEnum.PrepCase: + return Icons.location_on_outlined; + case MorphFeaturesEnum.ConjType: + return Icons.compare_arrows; + default: + return Icons.help_outline; + } + } +} + +String? getMorphologicalCategoryCopy( + String categoryName, + BuildContext context, +) { + final MorphFeaturesEnum category = + MorphFeaturesEnumExtension.fromString(categoryName); + return category.getDisplayCopy(context); +} diff --git a/lib/pangea/morphs/morph_icon.dart b/lib/pangea/morphs/morph_icon.dart index b4b27500c..90e60b074 100644 --- a/lib/pangea/morphs/morph_icon.dart +++ b/lib/pangea/morphs/morph_icon.dart @@ -1,37 +1,55 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; -import 'package:fluffychat/pangea/morphs/get_icon_for_morph_feature.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/get_svg_link.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/utils/color_value.dart'; +import 'package:flutter/material.dart'; class MorphIcon extends StatelessWidget { const MorphIcon({ super.key, required this.morphFeature, required this.morphTag, + this.size, + this.showTooltip = false, }); - final String morphFeature; + final MorphFeaturesEnum morphFeature; final String? morphTag; + final bool showTooltip; + final Size? size; @override Widget build(BuildContext context) { + // debugPrint("MorphIcon: morphFeature: $morphFeature, morphTag: $morphTag"); + final ThemeData theme = Theme.of(context); - return CustomizedSvg( - svgUrl: getMorphSvgLink( - morphFeature: morphFeature, - morphTag: morphTag, - context: context, + return Tooltip( + message: morphTag == null + ? morphFeature.getDisplayCopy(context) + : getGrammarCopy( + category: morphFeature.name, + lemma: morphTag!, + context: context, + ), + triggerMode: TooltipTriggerMode.tap, + child: CustomizedSvg( + svgUrl: getMorphSvgLink( + morphFeature: morphFeature.name, + morphTag: morphTag, + context: context, + ), + colorReplacements: theme.brightness == Brightness.dark + ? { + "white": theme.cardColor.hexValue.toString(), + "black": "white", + } + : {}, + errorIcon: Icon(morphFeature.fallbackIcon), + width: size?.width, + height: size?.height, ), - colorReplacements: theme.brightness == Brightness.dark - ? { - "white": theme.cardColor.hexValue.toString(), - "black": "white", - } - : {}, - errorIcon: Icon(getIconForMorphFeature(morphFeature)), ); } } diff --git a/lib/pangea/morphs/morph_tag_display.dart b/lib/pangea/morphs/morph_tag_display.dart index 7bb18cd99..b9e7d48e3 100644 --- a/lib/pangea/morphs/morph_tag_display.dart +++ b/lib/pangea/morphs/morph_tag_display.dart @@ -1,18 +1,19 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:flutter/material.dart'; class MorphTagDisplay extends StatelessWidget { const MorphTagDisplay({ super.key, - required String morphFeature, + required MorphFeaturesEnum morphFeature, + required String morphTag, required this.textColor, - }) : _morphFeature = morphFeature; + }) : _morphFeature = morphFeature, + _morphTag = morphTag; - final String _morphFeature; + final MorphFeaturesEnum _morphFeature; + final String _morphTag; final Color textColor; @override @@ -22,20 +23,19 @@ class MorphTagDisplay extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( - width: 24.0, - height: 24.0, - child: MorphIcon(morphFeature: _morphFeature, morphTag: null), + width: 32.0, + height: 32.0, + child: MorphIcon(morphFeature: _morphFeature, morphTag: _morphTag), ), const SizedBox(width: 10.0), Text( - _morphFeature.toLowerCase() == "other" - ? L10n.of(context).other - : ConstructTypeEnum.morph.getDisplayCopy( - _morphFeature, - context, - ) ?? - _morphFeature, - style: Theme.of(context).textTheme.titleMedium?.copyWith( + getGrammarCopy( + category: _morphFeature.name, + lemma: _morphTag, + context: context, + ) ?? + _morphTag, + style: Theme.of(context).textTheme.titleLarge?.copyWith( color: textColor, ), ), diff --git a/lib/pangea/morphs/parts_of_speech_enum.dart b/lib/pangea/morphs/parts_of_speech_enum.dart index a4b9c58ec..80133c7be 100644 --- a/lib/pangea/morphs/parts_of_speech_enum.dart +++ b/lib/pangea/morphs/parts_of_speech_enum.dart @@ -1,20 +1,27 @@ -import 'package:flutter/material.dart'; +import 'dart:developer'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +/// list ordered by priority +enum PartOfSpeechEnum { + //Content tokens + noun, + verb, + adj, + adv, -enum GrammarCopyPOS { + //Function tokens sconj, num, - verb, affix, part, - adj, cconj, punct, - adv, aux, space, sym, @@ -22,19 +29,18 @@ enum GrammarCopyPOS { pron, adp, propn, - noun, intj, x, } -extension GrammarCopyPOSExtension on GrammarCopyPOS { +extension PartOfSpeechEnumExtensions on PartOfSpeechEnum { /// Convert enum to string String toShortString() { return toString().split('.').last.toLowerCase(); } - GrammarCopyPOS? fromString(String categoryName) { - final pos = GrammarCopyPOS.values.firstWhereOrNull( + static PartOfSpeechEnum? fromString(String categoryName) { + final pos = PartOfSpeechEnum.values.firstWhereOrNull( (pos) => pos.toShortString() == categoryName.toLowerCase(), ); if (pos == null) { @@ -49,50 +55,103 @@ extension GrammarCopyPOSExtension on GrammarCopyPOS { String getDisplayCopy(BuildContext context) { switch (this) { - case GrammarCopyPOS.sconj: + case PartOfSpeechEnum.sconj: return L10n.of(context).grammarCopyPOSsconj; - case GrammarCopyPOS.num: + case PartOfSpeechEnum.num: return L10n.of(context).grammarCopyPOSnum; - case GrammarCopyPOS.verb: + case PartOfSpeechEnum.verb: return L10n.of(context).grammarCopyPOSverb; - case GrammarCopyPOS.affix: + case PartOfSpeechEnum.affix: return L10n.of(context).grammarCopyPOSaffix; - case GrammarCopyPOS.part: + case PartOfSpeechEnum.part: return L10n.of(context).grammarCopyPOSpart; - case GrammarCopyPOS.adj: + case PartOfSpeechEnum.adj: return L10n.of(context).grammarCopyPOSadj; - case GrammarCopyPOS.cconj: + case PartOfSpeechEnum.cconj: return L10n.of(context).grammarCopyPOScconj; - case GrammarCopyPOS.punct: + case PartOfSpeechEnum.punct: return L10n.of(context).grammarCopyPOSpunct; - case GrammarCopyPOS.adv: + case PartOfSpeechEnum.adv: return L10n.of(context).grammarCopyPOSadv; - case GrammarCopyPOS.aux: + case PartOfSpeechEnum.aux: return L10n.of(context).grammarCopyPOSaux; - case GrammarCopyPOS.space: + case PartOfSpeechEnum.space: return L10n.of(context).grammarCopyPOSspace; - case GrammarCopyPOS.sym: + case PartOfSpeechEnum.sym: return L10n.of(context).grammarCopyPOSsym; - case GrammarCopyPOS.det: + case PartOfSpeechEnum.det: return L10n.of(context).grammarCopyPOSdet; - case GrammarCopyPOS.pron: + case PartOfSpeechEnum.pron: return L10n.of(context).grammarCopyPOSpron; - case GrammarCopyPOS.adp: + case PartOfSpeechEnum.adp: return L10n.of(context).grammarCopyPOSadp; - case GrammarCopyPOS.propn: + case PartOfSpeechEnum.propn: return L10n.of(context).grammarCopyPOSpropn; - case GrammarCopyPOS.noun: + case PartOfSpeechEnum.noun: return L10n.of(context).grammarCopyPOSnoun; - case GrammarCopyPOS.intj: + case PartOfSpeechEnum.intj: return L10n.of(context).grammarCopyPOSintj; - case GrammarCopyPOS.x: + case PartOfSpeechEnum.x: return L10n.of(context).grammarCopyPOSx; } } + + bool get isContentWord => [ + PartOfSpeechEnum.noun, + PartOfSpeechEnum.verb, + PartOfSpeechEnum.adj, + PartOfSpeechEnum.adv, + ].contains(this); + + bool get canBeDefined => [ + PartOfSpeechEnum.noun, + PartOfSpeechEnum.verb, + PartOfSpeechEnum.adj, + PartOfSpeechEnum.adv, + PartOfSpeechEnum.propn, + PartOfSpeechEnum.intj, + PartOfSpeechEnum.det, + PartOfSpeechEnum.pron, + PartOfSpeechEnum.sconj, + PartOfSpeechEnum.cconj, + PartOfSpeechEnum.adp, + PartOfSpeechEnum.aux, + PartOfSpeechEnum.num, + ].contains(this); + + bool get canBeHeard => [ + PartOfSpeechEnum.noun, + PartOfSpeechEnum.verb, + PartOfSpeechEnum.adj, + PartOfSpeechEnum.adv, + PartOfSpeechEnum.propn, + PartOfSpeechEnum.intj, + PartOfSpeechEnum.det, + PartOfSpeechEnum.pron, + PartOfSpeechEnum.sconj, + PartOfSpeechEnum.cconj, + PartOfSpeechEnum.adp, + PartOfSpeechEnum.aux, + PartOfSpeechEnum.num, + ].contains(this); + + bool eligibleForPractice(ActivityTypeEnum activityType) { + switch (activityType) { + case ActivityTypeEnum.emoji: + case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.morphId: + return canBeDefined; + case ActivityTypeEnum.wordFocusListening: + return canBeHeard; + default: + debugger(when: kDebugMode); + return false; + } + } } String? getVocabCategoryName(String category, BuildContext context) { - return GrammarCopyPOS.values + return PartOfSpeechEnum.values .firstWhereOrNull((pos) => pos.toShortString() == category.toLowerCase()) ?.getDisplayCopy(context); } diff --git a/lib/pangea/toolbar/enums/activity_display_instructions_enum.dart b/lib/pangea/practice_activities/activity_display_instructions_enum.dart similarity index 100% rename from lib/pangea/toolbar/enums/activity_display_instructions_enum.dart rename to lib/pangea/practice_activities/activity_display_instructions_enum.dart diff --git a/lib/pangea/toolbar/enums/activity_type_enum.dart b/lib/pangea/practice_activities/activity_type_enum.dart similarity index 93% rename from lib/pangea/toolbar/enums/activity_type_enum.dart rename to lib/pangea/practice_activities/activity_type_enum.dart index 6ef056369..6a04b4f22 100644 --- a/lib/pangea/toolbar/enums/activity_type_enum.dart +++ b/lib/pangea/practice_activities/activity_type_enum.dart @@ -1,10 +1,6 @@ -import 'package:flutter/material.dart'; - -import 'package:material_symbols_icons/symbols.dart'; - -import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; enum ActivityTypeEnum { wordMeaning, @@ -156,18 +152,22 @@ extension ActivityTypeExtension on ActivityTypeEnum { } } - /// Filters out constructs that are not relevant to the activity type - bool Function(ConstructIdentifier) get constructFilter { + ConstructUseTypeEnum? get incorrectUse { switch (this) { case ActivityTypeEnum.wordMeaning: + return ConstructUseTypeEnum.incPA; case ActivityTypeEnum.wordFocusListening: + return ConstructUseTypeEnum.incWL; case ActivityTypeEnum.hiddenWordListening: + return ConstructUseTypeEnum.incHWL; case ActivityTypeEnum.lemmaId: + return ConstructUseTypeEnum.incL; case ActivityTypeEnum.emoji: - case ActivityTypeEnum.messageMeaning: - return (id) => id.type == ConstructTypeEnum.vocab; + return null; case ActivityTypeEnum.morphId: - return (id) => id.type == ConstructTypeEnum.morph; + return ConstructUseTypeEnum.incM; + case ActivityTypeEnum.messageMeaning: + return ConstructUseTypeEnum.incMM; } } @@ -177,7 +177,7 @@ extension ActivityTypeExtension on ActivityTypeEnum { return Icons.translate; case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: - return Icons.hearing; + return Icons.volume_up; case ActivityTypeEnum.lemmaId: return Symbols.dictionary; case ActivityTypeEnum.emoji: diff --git a/lib/pangea/toolbar/repo/emoji_activity_generator.dart b/lib/pangea/practice_activities/emoji_activity_generator.dart similarity index 62% rename from lib/pangea/toolbar/repo/emoji_activity_generator.dart rename to lib/pangea/practice_activities/emoji_activity_generator.dart index 5431f69bd..ada6e3c69 100644 --- a/lib/pangea/toolbar/repo/emoji_activity_generator.dart +++ b/lib/pangea/practice_activities/emoji_activity_generator.dart @@ -1,17 +1,15 @@ import 'dart:developer'; import 'dart:math'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; - class EmojiActivityGenerator { Future get( MessageActivityRequest req, @@ -22,11 +20,16 @@ class EmojiActivityGenerator { final PangeaToken token = req.targetTokens.first; final List emojis = await token.getEmojiChoices(); - final tokenEmoji = token.getEmoji(); - if (tokenEmoji != null && !emojis.contains(tokenEmoji)) { + final List tokenEmojis = token.getEmoji(); + //TODO : fix this or delete the file + if (tokenEmojis.isNotEmpty) { final Random random = Random(); - final int randomIndex = random.nextInt(emojis.length); - emojis[randomIndex] = tokenEmoji; + for (final emoji in tokenEmojis) { + final emojiIndex = emojis.indexOf(emoji); + if (emojiIndex != -1) continue; + final int randomIndex = random.nextInt(emojis.length); + emojis[randomIndex] = emoji; + } } // TODO - modify MultipleChoiceActivity flow to allow no correct answer diff --git a/lib/pangea/toolbar/repo/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart similarity index 90% rename from lib/pangea/toolbar/repo/lemma_activity_generator.dart rename to lib/pangea/practice_activities/lemma_activity_generator.dart index 87e9eb397..a352dc0a3 100644 --- a/lib/pangea/toolbar/repo/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -1,17 +1,15 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.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'; class LemmaActivityGenerator { Future get( diff --git a/lib/pangea/toolbar/repo/lemma_meaning_activity_generator.dart b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart similarity index 60% rename from lib/pangea/toolbar/repo/lemma_meaning_activity_generator.dart rename to lib/pangea/practice_activities/lemma_meaning_activity_generator.dart index 2ea835be1..89b2df3da 100644 --- a/lib/pangea/toolbar/repo/lemma_meaning_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_meaning_activity_generator.dart @@ -1,24 +1,20 @@ import 'dart:async'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart'; +import 'package:fluffychat/pangea/word_bank/vocab_request.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class LemmaMeaningActivityGenerator { - /// Cache whether a lemma has distractors for a given part of speech - static final Map _hasDistractorsCache = {}; - static Timer? _cacheClearTimer; - Future get( MessageActivityRequest req, ) async { @@ -38,7 +34,7 @@ class LemmaMeaningActivityGenerator { userL1: req.userL1, ); - final res = await LemmaInfoRepo.get(lemmaDefReq, null, true); + final res = await LemmaInfoRepo.get(lemmaDefReq); final choices = await getDistractorMeanings(lemmaDefReq, 3); @@ -64,40 +60,25 @@ class LemmaMeaningActivityGenerator { ); } - static List eligibleDistractors(String lemma, String pos) { - final distractors = - MatrixState.pangeaController.getAnalytics.constructListModel - .constructList(type: ConstructTypeEnum.vocab) - .where( - (c) => - c.lemma.isNotEmpty && // must not be empty strings - c.lemma.toLowerCase() != - lemma.toLowerCase() && // must not be the lemma itself - c.category.toLowerCase() == - pos.toLowerCase(), // must be same part of speech - ) - .toList(); - - _hasDistractorsCache['${lemma.toLowerCase()}-${pos.toLowerCase()}'] = - distractors.isNotEmpty; - - _cacheClearTimer ??= Timer.periodic(const Duration(minutes: 2), (Timer t) { - _hasDistractorsCache.clear(); - }); - - return distractors; - } - /// From the cache, get a random set of cached definitions that are not for a specific lemma static Future> getDistractorMeanings( LemmaInfoRequest req, int count, ) async { - final eligible = eligibleDistractors(req.lemma, req.partOfSpeech); - eligible.shuffle(); + final eligible = await VocabRepo.getSemanticallySimilarWords( + VocabRequest( + langCode: req.lemmaLang, + level: MatrixState + .pangeaController.userController.profile.userSettings.cefrLevel, + lemma: req.lemma, + pos: req.partOfSpeech, + count: count, + ), + ); + eligible.vocab.shuffle(); - final List distractorConstructUses = - eligible.take(count).toList(); + final List distractorConstructUses = + eligible.vocab.take(count).toList(); final List> futureDefs = []; for (final construct in distractorConstructUses) { diff --git a/lib/pangea/toolbar/models/message_activity_request.dart b/lib/pangea/practice_activities/message_activity_request.dart similarity index 96% rename from lib/pangea/toolbar/models/message_activity_request.dart rename to lib/pangea/practice_activities/message_activity_request.dart index c48b3641f..a92f144a3 100644 --- a/lib/pangea/toolbar/models/message_activity_request.dart +++ b/lib/pangea/practice_activities/message_activity_request.dart @@ -1,13 +1,11 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; // includes feedback text and the bad activity model class ActivityQualityFeedback { diff --git a/lib/pangea/practice_activities/message_analytics_controller.dart b/lib/pangea/practice_activities/message_analytics_controller.dart new file mode 100644 index 000000000..e5ea76dc5 --- /dev/null +++ b/lib/pangea/practice_activities/message_analytics_controller.dart @@ -0,0 +1,283 @@ +import 'dart:developer'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart'; +import 'package:flutter/foundation.dart'; + +class MessageAnalyticsEntry { + final DateTime createdAt = DateTime.now(); + + late final List _tokens; + + final Map> + _activityQueue = {}; + + final int _maxQueueLength = 5; + + MessageAnalyticsEntry({ + required List tokens, + required bool includeHiddenWordActivities, + required PangeaMessageEvent pangeaMessageEvent, + }) { + _tokens = tokens; + initialize(); + } + + void _pushQueue(TargetTokensAndActivityType entry) { + if (_activityQueue.containsKey(entry.activityType)) { + _activityQueue[entry.activityType]!.insert(0, entry); + } else { + _activityQueue[entry.activityType] = [entry]; + } + + // just in case we make a mistake and the queue gets too long + if (_activityQueue[entry.activityType]!.length > _maxQueueLength) { + debugger(when: kDebugMode); + _activityQueue[entry.activityType]!.removeRange( + _maxQueueLength, + _activityQueue.length, + ); + } + } + + void _filterActivityQueue(ActivityTypeEnum activityType) { + _activityQueue[activityType]?.clear(); + } + + void _clearAllQueue() { + _activityQueue.clear(); + } + + TargetTokensAndActivityType? nextActivity(ActivityTypeEnum a) => + _activityQueue[a]?.firstOrNull; + + bool get hasHiddenWordActivity => + activities(ActivityTypeEnum.hiddenWordListening).isNotEmpty; + + bool get hasMessageMeaningActivity => + activities(ActivityTypeEnum.messageMeaning).isNotEmpty; + + int get numActivities => _activityQueue.length; + + List activities(ActivityTypeEnum a) => + _activityQueue[a] ?? []; + + // /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening + // /// Otherwise, we don't have enough distractors + // bool get canDoWordFocusListening => + // _tokens.where((t) => t.canBeHeard).length > 4; + + /// On initialization, we pick which tokens to do activities on and what types of activities to do + void initialize() { + final eligibleTokens = _tokens.where((t) => t.lemma.saveVocab); + + // EMOJI + // sort the tokens by the preference of them for an emoji activity + // order from least to most recent + // words that have never been used are counted as 1000 days + // we preference content words over function words by multiplying the days since last use by 2 + // NOTE: for now, we put it at the end if it has no uses and basically just give them the answer + // later on, we may introduce an emoji activity that is easier than the current matching one + // i.e. we show them 3 good emojis and 1 bad one and ask them to pick the bad one + _activityQueue[ActivityTypeEnum.emoji] = eligibleTokens + .map( + (t) => TargetTokensAndActivityType( + tokens: [t], + activityType: ActivityTypeEnum.emoji, + ), + ) + .sorted( + (a, b) => a.tokens.first + .activityPriorityScore(ActivityTypeEnum.emoji, null) + .compareTo( + b.tokens.first + .activityPriorityScore(ActivityTypeEnum.emoji, null), + ), + ) + .take(_maxQueueLength) + .shuffled() + .toList(); + + // WORD MEANING + // make word meaning activities + // same as emojis for now + _activityQueue[ActivityTypeEnum.wordMeaning] = eligibleTokens + .map( + (t) => TargetTokensAndActivityType( + tokens: [t], + activityType: ActivityTypeEnum.wordMeaning, + ), + ) + .sorted( + (a, b) => a.tokens.first + .activityPriorityScore(ActivityTypeEnum.wordMeaning, null) + .compareTo( + b.tokens.first + .activityPriorityScore(ActivityTypeEnum.wordMeaning, null), + ), + ) + .take(_maxQueueLength) + .shuffled() + .toList(); + + // WORD FOCUS LISTENING + // make word focus listening activities + // same as emojis for now + _activityQueue[ActivityTypeEnum.wordFocusListening] = eligibleTokens + .map( + (t) => TargetTokensAndActivityType( + tokens: [t], + activityType: ActivityTypeEnum.wordFocusListening, + ), + ) + .sorted( + (a, b) => a.tokens.first + .activityPriorityScore(ActivityTypeEnum.wordFocusListening, null) + .compareTo( + b.tokens.first.activityPriorityScore( + ActivityTypeEnum.wordFocusListening, + null, + ), + ), + ) + .take(_maxQueueLength) + .shuffled() + .toList(); + + // GRAMMAR + // build a list of TargetTokensAndActivityType for all tokens and all features in the message + // limits to _maxQueueLength activities and only one per token + final List candidates = eligibleTokens.expand( + (t) { + return t.morphsBasicallyEligibleForPracticeByPriority.map( + (m) => TargetTokensAndActivityType( + tokens: [t], + activityType: ActivityTypeEnum.morphId, + morphFeature: MorphFeaturesEnumExtension.fromString(m.category), + ), + ); + }, + ).sorted( + (a, b) => a.tokens.first + .activityPriorityScore( + ActivityTypeEnum.morphId, + a.morphFeature!.name, + ) + .compareTo( + b.tokens.first.activityPriorityScore( + ActivityTypeEnum.morphId, + b.morphFeature!.name, + ), + ), + ); + //pick from the top 5, only including one per token + _activityQueue[ActivityTypeEnum.morphId] = []; + for (final candidate in candidates) { + if (_activityQueue[ActivityTypeEnum.morphId]!.length >= _maxQueueLength) { + break; + } + if (_activityQueue[ActivityTypeEnum.morphId]?.any( + (entry) => entry.tokens.contains(candidate.tokens.first), + ) == + false) { + _activityQueue[ActivityTypeEnum.morphId]?.add(candidate); + } + } + } + + bool hasActivity( + ActivityTypeEnum a, + PangeaToken t, [ + MorphFeaturesEnum? morph, + ]) => + _activityQueue[a]?.any( + (entry) => + entry.tokens.contains(t) && + (morph == null || entry.morphFeature == morph), + ) == + true; + + /// Add a message meaning activity to the front of the queue + /// And limits to _maxQueueLength activities + void addMessageMeaningActivity() { + final entry = TargetTokensAndActivityType( + tokens: _tokens, + activityType: ActivityTypeEnum.messageMeaning, + ); + _pushQueue(entry); + } + + void onActivityComplete(ActivityTypeEnum a, PangeaToken? token) { + _activityQueue[a] + ?.removeWhere((entry) => token == null || entry.tokens.contains(token)); + } + + void exitPracticeFlow() => _activityQueue.clear(); + + void revealAllTokens() => + _activityQueue[ActivityTypeEnum.hiddenWordListening]?.clear(); + + bool isTokenInHiddenWordActivity(PangeaToken token) => + _activityQueue[ActivityTypeEnum.hiddenWordListening]?.isNotEmpty ?? false; +} + +/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message +/// listens for analytics updates and updates the cache accordingly +class MessageAnalyticsController { + static final Map _cache = {}; + + void dispose() { + _cache.clear(); + } + + // if over 300, remove oldest 5 entries by createdAt + static void clean() { + if (_cache.length > 300) { + final sortedEntries = _cache.entries.toList() + ..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt)); + for (var i = 0; i < 5; i++) { + _cache.remove(sortedEntries[i].key); + } + } + } + + static String _key(List tokens) => + PangeaToken.reconstructText(tokens); + + static MessageAnalyticsEntry? get( + List tokens, + PangeaMessageEvent pangeaMessageEvent, + ) { + final String key = _key(tokens); + final entry = _cache[key]; + + // if cache is older than 1 day, then remove and recompute + if (entry != null && + DateTime.now().difference(entry.createdAt).inDays > 1) { + _cache.remove(key); + } + + if (entry != null) { + return entry; + } + + final bool includeHiddenWordActivities = !pangeaMessageEvent.ownMessage && + pangeaMessageEvent.messageDisplayRepresentation?.tokens != null && + pangeaMessageEvent.messageDisplayLangIsL2 && + !pangeaMessageEvent.event.isRichMessage; + + _cache[key] = MessageAnalyticsEntry( + tokens: tokens, + includeHiddenWordActivities: includeHiddenWordActivities, + pangeaMessageEvent: pangeaMessageEvent, + ); + + clean(); + + return _cache[key]; + } +} diff --git a/lib/pangea/toolbar/repo/morph_activity_generator.dart b/lib/pangea/practice_activities/morph_activity_generator.dart similarity index 89% rename from lib/pangea/toolbar/repo/morph_activity_generator.dart rename to lib/pangea/practice_activities/morph_activity_generator.dart index bcc5d1e7e..1332af918 100644 --- a/lib/pangea/toolbar/repo/morph_activity_generator.dart +++ b/lib/pangea/practice_activities/morph_activity_generator.dart @@ -1,19 +1,17 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; typedef MorphActivitySequence = Map; diff --git a/lib/pangea/toolbar/models/multiple_choice_activity_model.dart b/lib/pangea/practice_activities/multiple_choice_activity_model.dart similarity index 97% rename from lib/pangea/toolbar/models/multiple_choice_activity_model.dart rename to lib/pangea/practice_activities/multiple_choice_activity_model.dart index f5e0755dc..5e7f535b3 100644 --- a/lib/pangea/toolbar/models/multiple_choice_activity_model.dart +++ b/lib/pangea/practice_activities/multiple_choice_activity_model.dart @@ -1,13 +1,11 @@ import 'dart:developer'; +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:collection/collection.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; - class ActivityContent { final String question; diff --git a/lib/pangea/toolbar/models/practice_activity_model.dart b/lib/pangea/practice_activities/practice_activity_model.dart similarity index 96% rename from lib/pangea/toolbar/models/practice_activity_model.dart rename to lib/pangea/practice_activities/practice_activity_model.dart index 53b3a2105..056a57a15 100644 --- a/lib/pangea/toolbar/models/practice_activity_model.dart +++ b/lib/pangea/practice_activities/practice_activity_model.dart @@ -1,19 +1,17 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_display_instructions_enum.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_display_instructions_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class CandidateMessage { final String msgId; diff --git a/lib/pangea/toolbar/models/practice_activity_record_model.dart b/lib/pangea/practice_activities/practice_activity_record_model.dart similarity index 98% rename from lib/pangea/toolbar/models/practice_activity_record_model.dart rename to lib/pangea/practice_activities/practice_activity_record_model.dart index df3b4fb84..b780e3ff1 100644 --- a/lib/pangea/toolbar/models/practice_activity_record_model.dart +++ b/lib/pangea/practice_activities/practice_activity_record_model.dart @@ -5,13 +5,12 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:flutter/foundation.dart'; class PracticeActivityRecordModel { final String? question; diff --git a/lib/pangea/toolbar/repo/practice_repo.dart b/lib/pangea/practice_activities/practice_repo.dart similarity index 90% rename from lib/pangea/toolbar/repo/practice_repo.dart rename to lib/pangea/practice_activities/practice_repo.dart index 8d0438d2b..54252b28c 100644 --- a/lib/pangea/toolbar/repo/practice_repo.dart +++ b/lib/pangea/practice_activities/practice_repo.dart @@ -2,12 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:http/http.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/network/requests.dart'; @@ -15,15 +9,19 @@ import 'package:fluffychat/pangea/common/network/urls.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/emoji_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/lemma_meaning_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/repo/emoji_activity_generator.dart'; -import 'package:fluffychat/pangea/toolbar/repo/lemma_activity_generator.dart'; -import 'package:fluffychat/pangea/toolbar/repo/lemma_meaning_activity_generator.dart'; -import 'package:fluffychat/pangea/toolbar/repo/morph_activity_generator.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; +import 'package:matrix/matrix.dart'; /// Represents an item in the completion cache. class _RequestCacheItem { diff --git a/lib/pangea/practice_activities/target_tokens_and_activity_type.dart b/lib/pangea/practice_activities/target_tokens_and_activity_type.dart new file mode 100644 index 000000000..98d392c29 --- /dev/null +++ b/lib/pangea/practice_activities/target_tokens_and_activity_type.dart @@ -0,0 +1,48 @@ +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:flutter/foundation.dart'; + +/// Picks which tokens to do activities on and what types of activities to do +/// Caches result so that we don't have to recompute it +/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated +/// If we decided that the first token should have a hidden word listening, we need to remember that +/// Otherwise, the user might leave the chat, return, and see a different word hidden + +class TargetTokensAndActivityType { + /// this is the tokens involved in the activity + /// for most, this will be a single token + final List tokens; + final ActivityTypeEnum activityType; + + // this is only defined for morphId activities + final MorphFeaturesEnum? morphFeature; + + TargetTokensAndActivityType({ + required this.tokens, + required this.activityType, + this.morphFeature, + }) { + if (ActivityTypeEnum.hiddenWordListening != activityType && + tokens.length != 1) { + throw Exception("Only hiddenWordListening can have multiple tokens"); + } + if (ActivityTypeEnum.morphId == activityType && morphFeature == null) { + throw Exception("morphFeature must be defined for morphId activities"); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is TargetTokensAndActivityType && + listEquals(other.tokens, tokens) && + other.activityType == activityType && + other.morphFeature == morphFeature; + } + + @override + int get hashCode => + tokens.hashCode ^ activityType.hashCode ^ morphFeature.hashCode; +} diff --git a/lib/pangea/toolbar/repo/word_meaning_static_practice_activity_model.dart b/lib/pangea/practice_activities/word_meaning_static_practice_activity_model.dart similarity index 83% rename from lib/pangea/toolbar/repo/word_meaning_static_practice_activity_model.dart rename to lib/pangea/practice_activities/word_meaning_static_practice_activity_model.dart index f840df254..266c954bf 100644 --- a/lib/pangea/toolbar/repo/word_meaning_static_practice_activity_model.dart +++ b/lib/pangea/practice_activities/word_meaning_static_practice_activity_model.dart @@ -3,10 +3,10 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/lemmas/lemma.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; final wordMeaningStaticPracticeActivityModel = MessageActivityResponse( activity: PracticeActivityModel( diff --git a/lib/pangea/toolbar/enums/message_mode_enum.dart b/lib/pangea/toolbar/enums/message_mode_enum.dart index 1aaae0928..1bdec832b 100644 --- a/lib/pangea/toolbar/enums/message_mode_enum.dart +++ b/lib/pangea/toolbar/enums/message_mode_enum.dart @@ -1,10 +1,15 @@ +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:fluffychat/config/app_config.dart'; - enum MessageMode { practiceActivity, @@ -16,7 +21,7 @@ enum MessageMode { // wordZoomSpeechToText, messageMeaning, - messageTextToSpeech, + listening, messageSpeechToText, messageTranslation, @@ -28,9 +33,9 @@ extension MessageModeExtension on MessageMode { IconData get icon { switch (this) { case MessageMode.messageTranslation: - return Icons.g_translate; - case MessageMode.messageTextToSpeech: - return Symbols.text_to_speech; + return Icons.translate; + case MessageMode.listening: + return Icons.volume_up; case MessageMode.messageSpeechToText: return Symbols.speech_to_text; case MessageMode.practiceActivity: @@ -43,7 +48,7 @@ extension MessageModeExtension on MessageMode { case MessageMode.messageMeaning: return Icons.star; case MessageMode.wordEmoji: - return Icons.emoji_emotions; + return Icons.add_reaction_outlined; case MessageMode.wordMorph: return Symbols.toys_and_games; } @@ -53,7 +58,7 @@ extension MessageModeExtension on MessageMode { switch (this) { case MessageMode.messageTranslation: return L10n.of(context).translations; - case MessageMode.messageTextToSpeech: + case MessageMode.listening: return L10n.of(context).messageAudio; case MessageMode.messageSpeechToText: return L10n.of(context).speechToTextTooltip; @@ -69,7 +74,7 @@ extension MessageModeExtension on MessageMode { case MessageMode.wordEmoji: return "Emoji"; case MessageMode.wordMorph: - return "Morph"; + return "Grammar"; case MessageMode.wordMeaning: return "Meaning"; } @@ -79,8 +84,8 @@ extension MessageModeExtension on MessageMode { switch (this) { case MessageMode.messageTranslation: return L10n.of(context).translationTooltip; - case MessageMode.messageTextToSpeech: - return L10n.of(context).audioTooltip; + case MessageMode.listening: + return L10n.of(context).listen; case MessageMode.messageSpeechToText: return L10n.of(context).speechToTextTooltip; case MessageMode.practiceActivity: @@ -95,42 +100,64 @@ extension MessageModeExtension on MessageMode { case MessageMode.wordEmoji: return "Emoji"; case MessageMode.wordMorph: - return "Morph"; + return "Grammar"; case MessageMode.wordMeaning: return "Meaning"; } } + InstructionsEnum? get instructionsEnum { + switch (this) { + case MessageMode.wordMorph: + return InstructionsEnum.chooseMorphs; + case MessageMode.messageSpeechToText: + return InstructionsEnum.speechToText; + case MessageMode.wordMeaning: + return InstructionsEnum.chooseLemmaMeaning; + case MessageMode.listening: + return InstructionsEnum.chooseWordAudio; + case MessageMode.wordEmoji: + return InstructionsEnum.chooseEmoji; + case MessageMode.noneSelected: + return InstructionsEnum.readingAssistanceOverview; + case MessageMode.messageTranslation: + case MessageMode.messageMeaning: + case MessageMode.wordZoom: + case MessageMode.practiceActivity: + return null; + } + } + double get pointOnBar { switch (this) { - case MessageMode.practiceActivity: - return 0; - case MessageMode.messageTextToSpeech: - return 0.35; - case MessageMode.messageTranslation: - return 0.64; - case MessageMode.messageMeaning: + // case MessageMode.stats: + // return 1; + case MessageMode.noneSelected: return 1; + case MessageMode.wordMorph: + return 0.7; + case MessageMode.wordMeaning: + return 0.5; + case MessageMode.listening: + return 0.3; + case MessageMode.messageTranslation: case MessageMode.messageSpeechToText: case MessageMode.wordZoom: - case MessageMode.noneSelected: case MessageMode.wordEmoji: - case MessageMode.wordMorph: - case MessageMode.wordMeaning: + case MessageMode.messageMeaning: + case MessageMode.practiceActivity: return 0; } } bool isUnlocked( - double proportionOfActivitiesCompleted, - bool totallyDone, + MessageOverlayController overlayController, ) { switch (this) { case MessageMode.messageTranslation: - case MessageMode.messageTextToSpeech: - return proportionOfActivitiesCompleted >= pointOnBar || totallyDone; + return overlayController.isTranslationUnlocked; case MessageMode.practiceActivity: - return !totallyDone; + case MessageMode.listening: case MessageMode.messageSpeechToText: case MessageMode.messageMeaning: case MessageMode.wordZoom: @@ -144,28 +171,40 @@ extension MessageModeExtension on MessageMode { bool get showButton => this != MessageMode.practiceActivity; + bool isModeDone(MessageOverlayController overlayController) { + switch (this) { + case MessageMode.messageTranslation: + return overlayController.isTotallyDone; + case MessageMode.listening: + return overlayController.isListeningDone; + case MessageMode.wordEmoji: + return overlayController.isEmojiDone; + case MessageMode.wordMorph: + return overlayController.isMorphDone; + case MessageMode.wordMeaning: + return overlayController.isMeaningDone; + default: + return false; + } + } + Color iconButtonColor( BuildContext context, - MessageMode currentMode, - double proportionOfActivitiesUnlocked, - bool totallyDone, + MessageOverlayController overlayController, ) { - if (this == MessageMode.practiceActivity && totallyDone) { + if (overlayController.isTotallyDone) { return AppConfig.gold; } //locked - if (!isUnlocked(proportionOfActivitiesUnlocked, totallyDone)) { + if (!isUnlocked(overlayController)) { return barAndLockedButtonColor(context); } - //unlocked and active - if (this == currentMode) { - return totallyDone ? AppConfig.gold : AppConfig.primaryColorLight; - } - - //unlocked and inactive - return Theme.of(context).colorScheme.primaryContainer; + //unlocked + return isModeDone(overlayController) + ? AppConfig.gold + : Theme.of(context).colorScheme.primaryContainer; } static Color barAndLockedButtonColor(BuildContext context) { @@ -173,4 +212,172 @@ extension MessageModeExtension on MessageMode { ? Colors.grey[800]! : Colors.grey[200]!; } + + ActivityTypeEnum? get associatedActivityType { + switch (this) { + case MessageMode.wordMeaning: + return ActivityTypeEnum.wordMeaning; + case MessageMode.listening: + return ActivityTypeEnum.wordFocusListening; + + case MessageMode.wordEmoji: + return ActivityTypeEnum.emoji; + + case MessageMode.wordMorph: + return ActivityTypeEnum.morphId; + + case MessageMode.noneSelected: + case MessageMode.messageMeaning: + case MessageMode.messageTranslation: + case MessageMode.wordZoom: + case MessageMode.messageSpeechToText: + case MessageMode.practiceActivity: + return null; + } + } + + /// returns a nullable string of the current level of the message + /// if string is null, then user has completed all levels + /// should be resolvable into a part of speech or morph feature using fromString + /// of the respective enum, PartOfSpeechEnum or MorphFeatureEnum + String? currentChoiceMode( + MessageOverlayController overlayController, + PangeaMessageEvent pangeaMessage, + ) { + switch (this) { + case MessageMode.wordMeaning: + case MessageMode.listening: + case MessageMode.wordEmoji: + // get the pos with some tokens left to practice, from most to least important for learning + return pangeaMessage.messageDisplayRepresentation! + .posSetToPractice(associatedActivityType!) + .firstWhereOrNull( + (pos) => pangeaMessage.messageDisplayRepresentation!.tokens!.any( + (t) => t.vocabConstructID.isActivityProbablyLevelAppropriate( + associatedActivityType!, + t.text.content, + ), + ), + ) + ?.name; + + case MessageMode.wordMorph: + // get the morph feature with some tokens left to practice, from most to least important for learning + return pangeaMessage + .messageDisplayRepresentation!.morphFeatureSetToPractice + .firstWhereOrNull( + (feature) => + pangeaMessage.messageDisplayRepresentation!.tokens!.any((t) { + final String? morphTag = t.getMorphTag(feature.name); + + if (morphTag == null) { + return false; + } + + return ConstructIdentifier( + lemma: morphTag, + type: ConstructTypeEnum.morph, + category: feature.name, + ).isActivityProbablyLevelAppropriate( + associatedActivityType!, + t.text.content, + ); + }), + ) + ?.name; + + case MessageMode.noneSelected: + case MessageMode.messageMeaning: + case MessageMode.messageTranslation: + case MessageMode.wordZoom: + case MessageMode.messageSpeechToText: + case MessageMode.practiceActivity: + return null; + } + + // final feature = MorphFeaturesEnumExtension.fromString(overlayController); + + // if (feature != null) { + // for (int i; i < pangeaMessage.messageDisplayRepresentation!.morphFeatureSetToPractice.length; i++) { + // if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) { + // return i; + // } + // } + + // for (final feature in pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature)) ?? []) { + // if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) { + // return feature.index; + // } + // } + // } + } + + // List messageModeChoiceLevel( + // MessageOverlayController overlayController, + // PangeaMessageEvent pangeaMessage, + // ) { + // switch (this) { + // case MessageMode.wordMorph: + // final morphFeatureSet = pangeaMessage + // .messageDisplayRepresentation?.morphFeatureSetToPractice; + + // if (morphFeatureSet == null) { + // debugger(when: kDebugMode); + // return []; + // } + + // // sort by the list of priority of parts of speech, defined by their order in the enum + // morphFeatureSet.toList().sort((a, b) => a.index.compareTo(b.index)); + + // debugPrint( + // "morphFeatureSet: ${morphFeatureSet.map((e) => e.name).toList()}", + // ); + // return morphFeatureSet + // .map( + // (feature) => MessageModeChoiceLevelWidget( + // overlayController: overlayController, + // pangeaMessageEvent: pangeaMessage, + // morphFeature: feature, + // ), + // ) + // .toList(); + // case MessageMode.noneSelected: + // case MessageMode.messageMeaning: + // case MessageMode.messageTranslation: + // case MessageMode.messageTextToSpeech: + // case MessageMode.messageSpeechToText: + // case MessageMode.practiceActivity: + // case MessageMode.wordZoom: + // case MessageMode.wordMeaning: + // case MessageMode.wordEmoji: + // if (associatedActivityType == null) { + // debugger(when: kDebugMode); + // return []; + // } + // final posSet = pangeaMessage.messageDisplayRepresentation + // ?.posSetToPractice(associatedActivityType!); + + // if (posSet == null) { + // debugger(when: kDebugMode); + // return []; + // } + + // // sort by the list of priority of parts of speech, defined by their order in the enum + // posSet.toList().sort((a, b) => a.index.compareTo(b.index)); + + // debugPrint("posSet: ${posSet.map((e) => e.name).toList()}"); + + // final widgets = posSet + // .map( + // (pos) => MessageModeChoiceLevelWidget( + // partOfSpeech: pos, + // overlayController: overlayController, + // pangeaMessageEvent: pangeaMessage, + // ), + // ) + // .toList(); + + // return widgets; + // } + // } } diff --git a/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart b/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart index 5c4f29c3f..a9a5cf3d9 100644 --- a/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart +++ b/lib/pangea/toolbar/event_wrappers/practice_activity_event.dart @@ -1,12 +1,11 @@ import 'dart:developer'; +import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart'; import 'package:flutter/foundation.dart'; - import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; import '../../events/constants/pangea_event_types.dart'; class PracticeActivityEvent { diff --git a/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart b/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart index 629a1687e..379b49042 100644 --- a/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart +++ b/lib/pangea/toolbar/event_wrappers/practice_activity_record_event.dart @@ -1,7 +1,7 @@ +import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart'; import '../../events/constants/pangea_event_types.dart'; class PracticeActivityRecordEvent { diff --git a/lib/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart b/lib/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart new file mode 100644 index 000000000..a8d728ac2 --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart @@ -0,0 +1,20 @@ +import 'package:fluffychat/pangea/constructs/construct_form.dart'; + +class MatchFeedback { + ConstructForm form; + bool isCorrect; + + MatchFeedback({required this.form, required this.isCorrect}); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is MatchFeedback && + other.form == form && + other.isCorrect == isCorrect; + } + + @override + int get hashCode => form.hashCode ^ isCorrect.hashCode; +} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice.dart deleted file mode 100644 index 4126ab1c5..000000000 --- a/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:collection/collection.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/app_emojis.dart'; -import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; - -class MessageEmojiChoice extends StatelessWidget { - final List? tokens; - final ChatController controller; - final MessageOverlayController overlayController; - - const MessageEmojiChoice({ - super.key, - required this.tokens, - required this.controller, - required this.overlayController, - }); - - Future redactReaction(BuildContext context, String emoji) { - if (!context.mounted) { - return Future.value(); - } - final evt = allReactionEvents.firstWhereOrNull( - (e) => - e.senderId == e.room.client.userID && - e.content.tryGetMap('m.relates_to')?['key'] == emoji, - ); - if (evt != null) { - return showFutureLoadingDialog( - context: context, - future: () => evt.redactEvent(), - ); - } - return Future.value(); - } - - Iterable get allReactionEvents => controller.selectedEvents.first - .aggregatedEvents( - controller.timeline!, - RelationshipTypes.reaction, - ) - .where( - (event) => - event.senderId == event.room.client.userID && - event.type == 'm.reaction', - ); - - bool alreadyInReactions(String emoji) { - for (final event in allReactionEvents) { - try { - if (event.content.tryGetMap('m.relates_to')!['key'] == emoji) { - return true; - } - } catch (_) {} - } - return false; - } - - void onDoubleTapOrLongPress(BuildContext context, String emoji) { - if (alreadyInReactions(emoji)) { - redactReaction(context, emoji); - } else { - controller.sendEmojiAction(emoji); - } - } - - List standardEmojiChoices(BuildContext context) => AppEmojis.emojis - .map( - (emoji) => MessageEmojiChoiceItem( - content: emoji, - onTap: () => alreadyInReactions(emoji) - ? redactReaction(context, emoji) - : controller.sendEmojiAction(emoji), - isSelected: false, - onDoubleTap: () => onDoubleTapOrLongPress(context, emoji), - onLongPress: () => onDoubleTapOrLongPress(context, emoji), - greenHighlight: false, - ), - ) - .toList(); - - List perTokenEmoji(BuildContext context) => - tokens!.where((token) => token.lemma.saveVocab).map((token) { - final bool greenHighlight = token.shouldDoActivity( - a: ActivityTypeEnum.wordMeaning, - feature: null, - tag: null, - ); - if (!token.lemma.saveVocab) { - return MessageEmojiChoiceItem( - content: token.text.content, - onTap: () => {}, - isSelected: overlayController.isTokenSelected(token), - onDoubleTap: null, - onLongPress: null, - greenHighlight: greenHighlight, - ); - } - - final emoji = token.getEmoji(); - - if (emoji == null) { - return MessageEmojiChoiceItem( - topContent: token.vocabConstruct.constructLevel.icon(), - content: token.text.content, - onTap: () => overlayController.onClickOverlayMessageToken(token), - onDoubleTap: null, - onLongPress: null, - isSelected: overlayController.isTokenSelected(token), - contentOpacity: 0.1, - greenHighlight: greenHighlight, - ); - } - - return MessageEmojiChoiceItem( - topContent: Text( - emoji, - style: const TextStyle(fontSize: 24), - ), - content: token.text.content, - onTap: () => overlayController.onClickOverlayMessageToken(token), - onDoubleTap: () => onDoubleTapOrLongPress(context, emoji), - onLongPress: () => onDoubleTapOrLongPress(context, emoji), - isSelected: overlayController.isTokenSelected(token), - greenHighlight: greenHighlight, - ); - }).toList(); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Wrap( - alignment: WrapAlignment.center, - // spacing: 8.0, // Adjust spacing between items - runSpacing: 0.0, // Adjust spacing between rows - children: tokens == null || - tokens!.isEmpty || - !(overlayController - .pangeaMessageEvent?.messageDisplayLangIsL2 ?? - false) - ? standardEmojiChoices(context) - : perTokenEmoji(context), - ), - ); - } -} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart index 05d04cd5b..762ea7712 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart @@ -1,6 +1,13 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; + +const Size emojiButtonSize = Size(60, 60); +BoxDecoration emojiButtonDecoration = BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), +); class MessageEmojiChoiceItem extends StatefulWidget { const MessageEmojiChoiceItem({ @@ -13,7 +20,8 @@ class MessageEmojiChoiceItem extends StatefulWidget { this.onLongPress, required this.isSelected, this.contentOpacity = 1.0, - required this.greenHighlight, + required this.isGold, + required this.token, }); final Widget? topContent; @@ -24,7 +32,8 @@ class MessageEmojiChoiceItem extends StatefulWidget { final bool isSelected; final double textSize; final double contentOpacity; - final bool greenHighlight; + final PangeaToken? token; + final bool? isGold; @override MessageEmojiChoiceItemState createState() => MessageEmojiChoiceItemState(); @@ -33,8 +42,56 @@ class MessageEmojiChoiceItem extends StatefulWidget { class MessageEmojiChoiceItemState extends State { bool _isHovered = false; + @override + didUpdateWidget(MessageEmojiChoiceItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isSelected != widget.isSelected || + oldWidget.isGold != widget.isGold) { + setState(() {}); + } + } + + Color get color { + if (widget.isSelected) { + debugPrint('widget.isGold: ${widget.isGold}'); + if (widget.isGold == null) { + return AppConfig.primaryColor.withAlpha((0.4 * 255).toInt()); + } else { + return widget.isGold! + ? AppConfig.success.withAlpha((0.4 * 255).toInt()) + : AppConfig.warning.withAlpha((0.4 * 255).toInt()); + } + } + if (_isHovered) { + return AppConfig.primaryColor.withAlpha((0.2 * 255).toInt()); + } + return Colors.transparent; + } + @override Widget build(BuildContext context) { +<<<<<<< HEAD + return Opacity( + opacity: widget.contentOpacity, + child: MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: InkWell( + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: Container( + height: emojiButtonSize.height, + width: emojiButtonSize.width, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + child: Text( + widget.content, + style: TextStyle(fontSize: widget.textSize - 2), +======= return IntrinsicWidth( child: Align( alignment: Alignment.center, @@ -80,6 +137,7 @@ class MessageEmojiChoiceItemState extends State { ], ), ), +>>>>>>> main ), ), ), diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart new file mode 100644 index 000000000..912eab68f --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart @@ -0,0 +1,120 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/constructs/construct_form.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MessageMatchActivity extends StatelessWidget { + final MessageOverlayController overlayController; + + const MessageMatchActivity({ + super.key, + required this.overlayController, + }); + + ActivityTypeEnum? get activityType => + overlayController.toolbarMode.associatedActivityType; + + List choices(TargetTokensAndActivityType a) { + switch (activityType) { + case ActivityTypeEnum.emoji: + return overlayController + .messageLemmaInfos![a.tokens.first.vocabConstructID]!.emoji + .take(maxEmojisPerLemma) + .toList(); + case ActivityTypeEnum.wordMeaning: + return [ + overlayController + .messageLemmaInfos![a.tokens.first.vocabConstructID]!.meaning, + ]; + case ActivityTypeEnum.wordFocusListening: + return [a.tokens.first.text.content]; + default: + debugger(when: kDebugMode); + return []; + } + } + + Widget choiceDisplayContent(TargetTokensAndActivityType a, String choice) { + switch (activityType) { + case ActivityTypeEnum.emoji: + return Text( + choice, + style: const TextStyle(fontSize: 26), + ); + case ActivityTypeEnum.wordMeaning: + return Text( + choice, + style: const TextStyle(fontSize: 26), + ); + case ActivityTypeEnum.wordFocusListening: + return const Icon( + Icons.volume_up, + size: 26, + ); + default: + debugger(when: kDebugMode); + return const SizedBox(); + } + } + + @override + Widget build(BuildContext context) { + if (overlayController.messageAnalyticsEntry == null || + overlayController.messageLemmaInfos == null || + activityType == null) { + debugger(when: kDebugMode); + return const SizedBox(); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + spacing: 16.0, + children: [ + if (overlayController.toolbarMode == MessageMode.listening && + overlayController.pangeaMessageEvent != null) + MessageAudioCard( + messageEvent: overlayController.pangeaMessageEvent!, + overlayController: overlayController, + setIsPlayingAudio: overlayController.setIsPlayingAudio, + ), + Wrap( + alignment: WrapAlignment.center, + spacing: 8.0, // Adjust spacing between items + runSpacing: 8.0, // Adjust spacing between rows + children: overlayController.messageAnalyticsEntry! + .activities(activityType!) + .expand( + (TargetTokensAndActivityType a) { + return choices(a).map( + (choice) => MessageMatchActivityItem( + constructForm: ConstructForm( + choice, + a.tokens.first.vocabConstructID, + ), + content: choiceDisplayContent(a, choice), + audioContent: + overlayController.toolbarMode == MessageMode.listening + ? a.tokens.first.text.content + : null, + overlayController: overlayController, + fixedSize: a.activityType == ActivityTypeEnum.wordMeaning + ? null + : 60, + ), + ); + }, + ).toList(), + ), + ], + ); + } +} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart new file mode 100644 index 000000000..0b2e1750c --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart @@ -0,0 +1,165 @@ +import 'dart:developer'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_form.dart'; +import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class MessageMatchActivityItem extends StatefulWidget { + const MessageMatchActivityItem({ + super.key, + required this.content, + required this.constructForm, + this.audioContent, + required this.overlayController, + required this.fixedSize, + }); + + final Widget content; + final ConstructForm constructForm; + final String? audioContent; + final MessageOverlayController overlayController; + final double? fixedSize; + + @override + MessageMatchActivityItemState createState() => + MessageMatchActivityItemState(); +} + +class MessageMatchActivityItemState extends State { + bool _isHovered = false; + bool _isPlaying = false; + + TtsController get tts => + widget.overlayController.widget.chatController.choreographer.tts; + + bool get isSelected => + widget.overlayController.selectedChoice == widget.constructForm; + + Future play() async { + if (widget.audioContent == null) { + return; + } + + if (_isPlaying) { + await tts.stop(); + if (mounted) { + setState(() => _isPlaying = false); + } + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + try { + await tts.tryToSpeak( + widget.audioContent!, + context, + targetID: 'word-audio-button', + ); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + s: s, + data: {"text": widget.audioContent}, + ); + } finally { + if (mounted) { + setState(() => _isPlaying = false); + } + } + } + } + + MatchFeedback? get wasChosen => widget.overlayController.feedbackStates + .firstWhereOrNull((e) => e.form == widget.constructForm); + + Color color(BuildContext context) { + final feedback = wasChosen; + if (feedback != null) { + return feedback.isCorrect ? AppConfig.success : AppConfig.warning; + } + + if (isSelected) { + return AppConfig.primaryColor; + } + + if (_isHovered) { + return Theme.of(context).colorScheme.primary; + } + + return Colors.transparent; + } + + @override + didUpdateWidget(MessageMatchActivityItem oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.overlayController.selectedChoice != + widget.overlayController.selectedChoice || + oldWidget.overlayController.selectedToken != + widget.overlayController.selectedToken || + widget.overlayController.feedbackStates + .any((e) => e.form == widget.constructForm) != + oldWidget.overlayController.feedbackStates + .any((e) => e.form == widget.constructForm)) { + setState(() {}); + } + } + + IntrinsicWidth content(BuildContext context) { + return IntrinsicWidth( + child: Container( + height: widget.fixedSize, + width: widget.fixedSize, + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color(context).withAlpha((0.4 * 255).toInt()), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + border: isSelected + ? Border.all( + color: color(context), + width: 2, + ) + : Border.all( + color: Colors.transparent, + width: 2, + ), + ), + child: widget.content, + ), + ); + } + + @override + Widget build(BuildContext context) { + return LongPressDraggable( + data: widget.constructForm, + feedback: content(context), + delay: const Duration(milliseconds: 100), + onDragStarted: () { + widget.overlayController.onChoiceSelect(widget.constructForm, true); + }, + // onDragCompleted: () { + // debugger(when: kDebugMode); + // }, + // onDragEnd: (details) { + // // debugger(when: kDebugMode); + // }, + child: InkWell( + onHover: (isHovered) => setState(() => _isHovered = isHovered), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + onTap: () { + play(); + widget.overlayController.onChoiceSelect(widget.constructForm); + }, + child: content(context), + ), + ); + } +} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart new file mode 100644 index 000000000..591bf0dbe --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart @@ -0,0 +1,232 @@ +import 'dart:developer'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/morphs/morph_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_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'; + +// this widget will handle the content of the input bar when mode == MessageMode.wordMorph + +// if initializing, with a selectedToken then we should show an activity if one is available +// in this case, we'll set selectedMorph to the first morph available for the selected token + +// if initializing with a selectedMorph then we should show the first activity available for that morph +// if no activity available for that morph, then we should just show the details of the feature and tag + +// the details of a morph will allow the user to edit the morphological tag of that feature. + +const int numberOfMorphDistractors = 3; + +class MessageMorphInputBarContent extends StatefulWidget { + final MessageOverlayController overlayController; + final PangeaMessageEvent pangeaMessageEvent; + + const MessageMorphInputBarContent({ + super.key, + required this.overlayController, + required this.pangeaMessageEvent, + }); + + @override + MessageMorphInputBarContentState createState() => + MessageMorphInputBarContentState(); +} + +class MessageMorphInputBarContentState + extends State { + // bool initialized = false; + + String? selectedTag; + + // @override + // void initState() { + // super.initState(); + // } + + MessageOverlayController get overlay => widget.overlayController; + PangeaToken? get token => overlay.selectedToken; + MorphFeaturesEnum? get morph => overlay.selectedMorph; + + // void init() async { + // initialized = false; + // setState(() {}); + + // if (token != null && morph != null) { + // morphChoices = MorphsRepo.cached.getDisplayTags(morph); + // } + // initialized = true; + // setState(() {}); + // } + + @override + void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) { + if (morph != oldWidget.overlayController.selectedMorph || + token != oldWidget.overlayController.selectedToken) { + selectedTag = null; + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + List? get choices { + if (morph == null || + token == null || + overlay.messageAnalyticsEntry + ?.hasActivity(ActivityTypeEnum.morphId, token!, morph) == + false) { + return null; + } + + final tag = token!.getMorphTag(morph!.name)!; + + return MorphsRepo.cached + .getDisplayTags(morph!.name) + .where((other) => other.toLowerCase() != tag.toLowerCase()) + .toList() + .take(numberOfMorphDistractors) + .toList() + + [tag]; + } + + bool isCorrect(String tag) => token?.getMorphTag(morph!.name) == tag; + + Future onActivityChoice( + String choice, + ) async { + if (token == null || morph == null) { + ErrorHandler.logError( + m: "Token or morph is null in onTokenSelectionWithSelectedMorphs", + data: overlay.selectedToken?.toJson() ?? {}, + ); + debugger(when: kDebugMode); + return; + } + + selectedTag = choice; + + MatrixState.pangeaController.putAnalytics.setState( + AnalyticsStream( + eventId: overlay.pangeaMessageEvent!.eventId, + roomId: overlay.pangeaMessageEvent!.room.id, + constructs: [ + OneConstructUse( + useType: isCorrect(choice) + ? ConstructUseTypeEnum.corM + : ConstructUseTypeEnum.incM, + lemma: choice, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: overlay.pangeaMessageEvent!.room.id, + timeStamp: DateTime.now(), + eventId: overlay.pangeaMessageEvent!.eventId, + ), + category: morph!.name, + form: token!.text.content, + ), + ], + origin: AnalyticsUpdateOrigin.wordZoom, + ), + ); + + setState(() => {}); + + await Future.delayed( + const Duration(milliseconds: choiceArrayAnimationDuration), + ); + + // this removes just one of the options + // important because sometimes meanings are the same for different words + if (isCorrect(choice)) { + overlay.messageAnalyticsEntry + ?.onActivityComplete(ActivityTypeEnum.morphId, token); + } + + // kind of an odd way to do this, but should work + overlay.setState(() {}); + // setState(() {}); + } + + @override + Widget build(BuildContext context) { + if (choices != null) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + spacing: 16.0, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 16.0, + children: [ + MorphIcon( + morphFeature: morph!, + morphTag: null, + size: const Size(30, 30), + ), + Text( + L10n.of(context).whatIsTheMorphTag( + morph!.getDisplayCopy(context), + token!.text.content, + ), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + spacing: 8.0, // Adjust spacing between items + runSpacing: 8.0, // Adjust spacing between rows + children: choices! + .mapIndexed( + (index, choice) => MessageMorphChoiceItem( + cId: ConstructIdentifier( + lemma: choice, + type: ConstructTypeEnum.morph, + category: morph!.name, + ), + onTap: () => onActivityChoice(choice), + isSelected: selectedTag == choice, + isGold: selectedTag != null ? isCorrect(choice) : null, + ), + ) + .toList(), + ), + ], + ); + } + + if (token != null && morph != null) { + return MorphFocusWidget( + token: token!, + morphFeature: morph!.name, + pangeaMessageEvent: widget.pangeaMessageEvent, + overlayController: overlay, + onEditDone: () => overlay.setState(() {}), + ); + } + + return const Center( + child: Text("Select a grammar icon for activities and details."), + ); + } +} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart new file mode 100644 index 000000000..1920e6c51 --- /dev/null +++ b/lib/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart @@ -0,0 +1,104 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:flutter/material.dart'; + +class MessageMorphChoiceItem extends StatefulWidget { + const MessageMorphChoiceItem({ + super.key, + required this.onTap, + required this.isSelected, + required this.isGold, + required this.cId, + }); + + final ConstructIdentifier cId; + final void Function() onTap; + final bool isSelected; + final bool? isGold; + + @override + MessageMorphChoiceItemState createState() => MessageMorphChoiceItemState(); +} + +class MessageMorphChoiceItemState extends State { + bool _isHovered = false; + + @override + void didUpdateWidget(covariant MessageMorphChoiceItem oldWidget) { + if (oldWidget.isSelected != widget.isSelected || + oldWidget.isGold != widget.isGold) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + Future onTap() async { + widget.onTap(); + } + + Color get _color { + if (widget.isSelected && widget.isGold != null) { + return widget.isGold! + ? AppConfig.success.withAlpha((0.4 * 255).toInt()) + : AppConfig.warning.withAlpha((0.4 * 255).toInt()); + } + if (widget.isSelected) { + return AppConfig.primaryColor.withAlpha((0.4 * 255).toInt()); + } + return _isHovered + ? AppConfig.primaryColor.withAlpha((0.2 * 255).toInt()) + : Colors.transparent; + } + + @override + Widget build(BuildContext context) { + return InkWell( + onHover: (isHovered) => setState(() => _isHovered = isHovered), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + onTap: onTap, + child: IntrinsicWidth( + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: _color, + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + ), + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 12.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8.0, + children: [ + SizedBox( + width: 40, + height: 40, + child: MorphIcon( + morphFeature: MorphFeaturesEnumExtension.fromString( + widget.cId.category, + ), + morphTag: widget.cId.lemma, + size: const Size(40, 40), + ), + ), + Text( + getGrammarCopy( + category: widget.cId.category, + lemma: widget.cId.lemma, + context: context, + ) ?? + widget.cId.lemma, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart b/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart index 7fafcbdda..73ea5d409 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/reading_assistance_input_bar.dart @@ -1,21 +1,21 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/word_emoji_choice.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_mode_locked_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_translation_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart'; -import 'message_emoji_choice.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; class ReadingAssistanceInputBar extends StatelessWidget { final ChatController controller; @@ -55,93 +55,42 @@ class ReadingAssistanceInputBar extends StatelessWidget { } Widget barContent(BuildContext context) { - if (token == null || - !(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ?? - false)) { - return MessageEmojiChoice( - tokens: overlayController - .pangeaMessageEvent?.messageDisplayRepresentation?.tokens ?? - [], - controller: controller, - overlayController: overlayController, - ); - } - switch (overlayController.toolbarMode) { // message meaning will not use the input bar (for now at least) // maybe we move some choices there later - case MessageMode.messageMeaning: - case MessageMode.messageTranslation: - case MessageMode.messageTextToSpeech: case MessageMode.messageSpeechToText: case MessageMode.practiceActivity: case MessageMode.wordZoom: case MessageMode.noneSelected: - return MessageEmojiChoice( - tokens: overlayController - .pangeaMessageEvent?.messageDisplayRepresentation?.tokens ?? - [], - controller: controller, + //TODO: show all emojis for the lemmas and allow sending normal reactions + return const SizedBox.shrink(); + // return MessageEmojiChoice( + // controller: controller, + // overlayController: overlayController, + // ); + + case MessageMode.messageTranslation: + if (overlayController.isTranslationUnlocked) { + return MessageTranslationCard( + messageEvent: overlayController.pangeaMessageEvent!, + ); + } else { + return MessageModeLockedCard(controller: overlayController); + } + + case MessageMode.wordEmoji: + case MessageMode.messageMeaning: + case MessageMode.wordMeaning: + case MessageMode.listening: + return MessageMatchActivity( overlayController: overlayController, ); - case MessageMode.wordEmoji: - return WordEmojiChoice( - form: overlayController.selectedToken!.text.content, - onEmojiChosen: () => - overlayController.onActivityFinish(ActivityTypeEnum.emoji), - constructID: overlayController.selectedToken!.vocabConstructID, - ); - - case MessageMode.wordMeaning: - return getPracticeActivityCard(ActivityTypeEnum.wordMeaning); - case MessageMode.wordMorph: - if (overlayController.selectedMorphFeature != null) { - if (!token!.shouldDoActivity( - a: ActivityTypeEnum.morphId, - feature: overlayController.selectedMorphFeature, - tag: token!.getMorphTag(overlayController.selectedMorphFeature!), - )) { - return MorphFocusWidget( - token: token!, - morphFeature: overlayController.selectedMorphFeature!, - pangeaMessageEvent: overlayController.pangeaMessageEvent!, - overlayController: overlayController, - onEditDone: () => overlayController.initializeTokensAndMode(), - ); - } else { - return getPracticeActivityCard( - ActivityTypeEnum.morphId, - overlayController.selectedMorphFeature, - ); - } - } else { - /// we're not supposed to be here actually - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "selectedMorphFeature is null in wordMorph mode", - s: StackTrace.current, - data: token?.toJson() ?? {}, - ); - final String? nextFeature = overlayController - .selectedToken?.nextMorphFeatureEligibleForActivity; - if (nextFeature != null) { - return getPracticeActivityCard( - ActivityTypeEnum.morphId, - nextFeature, - ); - } else { - // morph center widget with feature = "pos" - return MorphFocusWidget( - token: token!, - morphFeature: "pos", - pangeaMessageEvent: overlayController.pangeaMessageEvent!, - overlayController: overlayController, - onEditDone: () => overlayController.initializeTokensAndMode(), - ); - } - } + return MessageMorphInputBarContent( + overlayController: overlayController, + pangeaMessageEvent: overlayController.pangeaMessageEvent!, + ); } } @@ -158,9 +107,10 @@ class ReadingAssistanceInputBar extends StatelessWidget { return const SizedBox.shrink(); } - return Flexible( + return Expanded( child: Container( height: AppConfig.readingAssistanceInputBarHeight, + padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: const BorderRadius.all( @@ -168,7 +118,9 @@ class ReadingAssistanceInputBar extends StatelessWidget { ), ), alignment: Alignment.center, - child: barContent(context), + child: SingleChildScrollView( + child: barContent(context), + ), ), ); } diff --git a/lib/pangea/toolbar/widgets/measure_render_box.dart b/lib/pangea/toolbar/widgets/measure_render_box.dart new file mode 100644 index 000000000..4deea10e9 --- /dev/null +++ b/lib/pangea/toolbar/widgets/measure_render_box.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class MeasureRenderBox extends StatefulWidget { + final Widget child; + final ValueChanged? onChange; + + const MeasureRenderBox({ + super.key, + required this.child, + required this.onChange, + }); + + @override + MeasureRenderBoxState createState() => MeasureRenderBoxState(); +} + +class MeasureRenderBoxState extends State { + Offset? _lastOffset; + Size? _lastSize; + + void _updateOffset() { + if (widget.onChange == null) return; + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox != null) { + final offset = renderBox.localToGlobal(Offset.zero); + if (_lastOffset == null || + _lastOffset != offset || + _lastSize == null || + _lastSize != renderBox.size) { + _lastOffset = offset; + _lastSize = renderBox.size; + widget.onChange!(renderBox); + setState(() {}); + } + } + } + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => _updateOffset()); + return widget.child; + } +} diff --git a/lib/pangea/toolbar/widgets/message_audio_card.dart b/lib/pangea/toolbar/widgets/message_audio_card.dart index 827dca987..141a38a05 100644 --- a/lib/pangea/toolbar/widgets/message_audio_card.dart +++ b/lib/pangea/toolbar/widgets/message_audio_card.dart @@ -1,5 +1,4 @@ import 'dart:developer'; -import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -12,17 +11,13 @@ import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.da import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart'; class MessageAudioCard extends StatefulWidget { final PangeaMessageEvent messageEvent; final MessageOverlayController overlayController; - final PangeaTokenText? selection; - final TtsController tts; final Function(bool) setIsPlayingAudio; final VoidCallback? onError; @@ -30,9 +25,7 @@ class MessageAudioCard extends StatefulWidget { super.key, required this.messageEvent, required this.overlayController, - required this.tts, required this.setIsPlayingAudio, - this.selection, this.onError, }); @@ -44,92 +37,10 @@ class MessageAudioCardState extends State { bool _isLoading = false; PangeaAudioFile? audioFile; - int? sectionStartMS; - int? sectionEndMS; - @override void initState() { super.initState(); fetchAudio(); - - // initializeTTS(); - } - - // initializeTTS() async { - // tts.setupTTS().then((value) => setState(() {})); - // } - - @override - void didUpdateWidget(covariant oldWidget) { - // if (oldWidget.selection != widget.selection && widget.selection != null) { - // debugPrint('selection changed'); - // setSectionStartAndEndFromSelection(); - // playSelectionAudio(); - // } - super.didUpdateWidget(oldWidget); - } - - void setSectionStartAndEnd(int? start, int? end) => mounted - ? setState(() { - sectionStartMS = start; - sectionEndMS = end; - }) - : null; - - void setSectionStartAndEndFromSelection() async { - if (audioFile == null) { - // should never happen but just in case - debugger(when: kDebugMode); - return; - } - - if (audioFile!.duration == null) { - // should never happen but just in case - debugger(when: kDebugMode); - ErrorHandler.logError( - e: 'audioFile duration is null in MessageAudioCardState', - data: { - 'audioFile': audioFile, - }, - ); - return setSectionStartAndEnd(null, null); - } - - // if there is no selection, we don't need to do anything - // but clear the section start and end - if (widget.selection == null) { - return setSectionStartAndEnd(null, null); - } - - final PangeaTokenText selection = widget.selection!; - final List tokens = audioFile!.tokens; - - // find the token that corresponds to the selection - // set the start to the start of the token - // set the end to the start of the next token or to the duration of the audio if - // if there is no next token - for (int i = 0; i < tokens.length; i++) { - final TTSToken ttsToken = tokens[i]; - if (ttsToken.text.offset == selection.offset) { - return setSectionStartAndEnd( - max(ttsToken.startMS - 150, 0), - min(ttsToken.endMS + 150, audioFile!.duration!), - ); - } - } - - // if we didn't find the token, we should pause if debug and log an error - debugger(when: kDebugMode); - ErrorHandler.logError( - e: 'could not find token for selection in MessageAudioCardState', - data: { - 'selection': selection, - 'tokens': tokens, - 'sttTokens': audioFile!.tokens, - }, - ); - - setSectionStartAndEnd(null, null); } Future fetchAudio() async { @@ -151,7 +62,6 @@ class MessageAudioCardState extends State { ); } debugPrint("audio file is now: $audioFile. setting starts and ends..."); - setSectionStartAndEndFromSelection(); if (mounted) setState(() => _isLoading = false); } catch (e, s) { widget.onError?.call(); @@ -182,8 +92,8 @@ class MessageAudioCardState extends State { ? AudioPlayerWidget( null, matrixFile: audioFile, - sectionStartMS: sectionStartMS, - sectionEndMS: sectionEndMS, + sectionStartMS: null, + sectionEndMS: null, color: Theme.of(context).colorScheme.onPrimaryContainer, setIsPlayingAudio: widget.setIsPlayingAudio, fontSize: diff --git a/lib/pangea/toolbar/widgets/message_mode_locked_card.dart b/lib/pangea/toolbar/widgets/message_mode_locked_card.dart index c8f2af8cd..b22b4ece1 100644 --- a/lib/pangea/toolbar/widgets/message_mode_locked_card.dart +++ b/lib/pangea/toolbar/widgets/message_mode_locked_card.dart @@ -1,9 +1,7 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:flutter/material.dart'; class MessageModeLockedCard extends StatelessWidget { final MessageOverlayController controller; @@ -12,32 +10,24 @@ class MessageModeLockedCard extends StatelessWidget { @override Widget build(BuildContext context) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppConfig.toolbarMinWidth, - maxHeight: AppConfig.toolbarMaxHeight, - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.lock_outline, - size: 40, - color: Theme.of(context).colorScheme.primary, - ), - if (!InstructionsEnum.completeActivitiesToUnlock.isToggledOff) ...[ - const SizedBox(height: 8), - const InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.completeActivitiesToUnlock, - ), - ], - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Icon( + Icons.lock_outline, + size: 40, + color: Theme.of(context).colorScheme.primary, ), - ), + if (!InstructionsEnum.completeActivitiesToUnlock.isToggledOff) ...[ + const SizedBox(height: 8), + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.completeActivitiesToUnlock, + bold: true, + ), + ], + ], ); } } diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index fcfde8d52..a200b9e71 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -1,27 +1,37 @@ import 'dart:async'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; - import 'package:collection/collection.dart'; -import 'package:matrix/matrix.dart'; - +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/constructs/construct_form.dart'; +import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart'; import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/reading_assistance_content.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'; /// Controls data at the top level of the toolbar (mainly token / toolbar mode selection) class MessageSelectionOverlay extends StatefulWidget { @@ -52,54 +62,51 @@ class MessageSelectionOverlay extends StatefulWidget { class MessageOverlayController extends State with SingleTickerProviderStateMixin { + Event get event => widget._event; ///////////////////////////////////// /// Variables ///////////////////////////////////// MessageMode toolbarMode = MessageMode.noneSelected; - /// If doing a morphological activity, this is the selected morph feature. - String? selectedMorphFeature; + Map? messageLemmaInfos; + MorphFeaturesEnum? selectedMorph; + ConstructForm? selectedChoice; PangeaTokenText? _selectedSpan; + List? _highlightedTokens; bool initialized = false; bool isPlayingAudio = false; - /// 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 - String get targetText { - if (_selectedSpan == null || pangeaMessageEvent == null) { - return pangeaMessageEvent?.messageDisplayText ?? widget._event.body; - } + /// temporary stores of which matches have been made and + /// whether correct or not. allows user to quickly match + /// choices while we're still displaying feedback for previous + /// choices + final List feedbackStates = []; - return pangeaMessageEvent!.messageDisplayText.substring( - _selectedSpan!.offset, - _selectedSpan!.offset + _selectedSpan!.length, - ); - } + final GlobalKey wordZoomKey = GlobalKey(); - PangeaToken? get selectedToken => - pangeaMessageEvent?.messageDisplayRepresentation?.tokens - ?.firstWhereOrNull(isTokenSelected); - - /// Whether the overlay is currently displaying a selection - bool get isSelection => _selectedSpan != null || _highlightedTokens != null; - - PangeaTokenText? get selectedSpan => _selectedSpan; + // either a MorphFeatureEnum or a PartOfSpeechEnum + // String? modeLevel; ///////////////////////////////////// /// Lifecycle ///////////////////////////////////// + @override + void dispose() { + super.dispose(); + } + @override void initState() { - super.initState(); initializeTokensAndMode(); + super.initState(); } Future initializeTokensAndMode() async { try { + debugPrint("what"); RepresentationEvent? repEvent = pangeaMessageEvent?.messageDisplayRepresentation; repEvent ??= await _fetchNewRepEvent(); @@ -130,7 +137,24 @@ class MessageOverlayController extends State choreo: pangeaMessageEvent!.originalSent?.choreo, ); } + + // Get all the lemma infos + final messageVocabConstructIds = pangeaMessageEvent! + .messageDisplayRepresentation!.tokensToSave + .map((e) => e.vocabConstructID) + .toList(); + final List> lemmaInfoFutures = + messageVocabConstructIds + .map((token) => token.getLemmaInfo()) + .toList(); + final List lemmaInfos = + await Future.wait(lemmaInfoFutures); + messageLemmaInfos = Map.fromIterables( + messageVocabConstructIds, + lemmaInfos, + ); } catch (e, s) { + debugger(when: kDebugMode); ErrorHandler.logError( e: e, s: s, @@ -148,21 +172,20 @@ class MessageOverlayController extends State Future _setInitialToolbarMode() async { if (pangeaMessageEvent?.isAudioMessage ?? false) { - updateToolbarMode(MessageMode.messageSpeechToText); + updateToolbarMode(MessageMode.listening); return; } // 1) if we have a hidden word activity, then we should start with that - if (messageAnalyticsEntry?.nextActivity?.activityType == - ActivityTypeEnum.hiddenWordListening) { + if (messageAnalyticsEntry?.hasHiddenWordActivity ?? false) { updateToolbarMode(MessageMode.practiceActivity); return; } - if (selectedToken != null) { - updateToolbarMode(selectedToken!.modeForToken); - return; - } + // if (selectedToken != null) { + // updateToolbarMode(selectedToken!.modeForToken); + // return; + // } // Note: this setting is now hidden so this will always be false // leaving this here in case we want to bring it back @@ -183,18 +206,17 @@ class MessageOverlayController extends State } // should not already be involved in a hidden word activity - final isInHiddenWordActivity = - messageAnalyticsEntry!.isTokenInHiddenWordActivity( - widget._initialSelectedToken!, - ); + // final isInHiddenWordActivity = + // messageAnalyticsEntry!.isTokenInHiddenWordActivity( + // widget._initialSelectedToken!, + // ); - // whether the activity should generally be involved in an activity - final selected = - !isInHiddenWordActivity ? widget._initialSelectedToken : null; - - if (selected != null) { - _updateSelectedSpan(selected.text); + // // whether the activity should generally be involved in an activity + if (messageAnalyticsEntry?.hasHiddenWordActivity == true) { + return; } + + _updateSelectedSpan(widget._initialSelectedToken!.text); } ///////////////////////////////////// @@ -206,12 +228,21 @@ class MessageOverlayController extends State /// This is a workaround to prevent that error @override void setState(VoidCallback fn) { + // if (pangeaMessageEvent != null) { + // debugger(when: kDebugMode); + // modeLevel = toolbarMode.currentChoiceMode(this, pangeaMessageEvent!); + // } else { + // debugger(when: kDebugMode); + // } + final phase = SchedulerBinding.instance.schedulerPhase; if (mounted && (phase == SchedulerPhase.idle || phase == SchedulerPhase.postFrameCallbacks)) { // It's safe to call setState immediately try { + wordZoomKey.currentState?.setState(() {}); + super.setState(fn); } catch (e, s) { ErrorHandler.logError( @@ -236,114 +267,121 @@ class MessageOverlayController extends State } } - /// Update to [selectedSpan] - /// [forceMode] is used to force a specific mode - void _updateSelectedSpan( - PangeaTokenText selectedSpan, [ - MessageMode? forceMode, - ]) { - if (forceMode == null && selectedSpan == _selectedSpan) { - _selectedSpan = null; - updateToolbarMode(MessageMode.noneSelected); - setState(() {}); - return; - } - - _selectedSpan = selectedSpan; - - if (!(messageAnalyticsEntry?.hasHiddenWordActivity ?? false)) { - widget.chatController.choreographer.tts.tryToSpeak( - selectedSpan.content, - context, - targetID: null, + /// Update [selectedSpan] + void _updateSelectedSpan(PangeaTokenText selectedSpan, [bool force = false]) { + // close overlay of previous token + if (selectedToken != null) { + MatrixState.pAnyState.disposeByWidgetKey( + selectedToken!.text.uniqueKey, ); } - final nextModeForToken = forceMode ?? selectedToken!.modeForToken; - if (toolbarMode != nextModeForToken) { - debugPrint("_updateSelectedSpan: setting toolbarMode to wordZoom"); - updateToolbarMode(nextModeForToken); + if (selectedSpan == _selectedSpan && !force) { + _selectedSpan = null; + } else { + _selectedSpan = selectedSpan; + } + + if (selectedToken != null) { + final entry = ReadingAssistanceContent( + key: wordZoomKey, + pangeaMessageEvent: pangeaMessageEvent!, + overlayController: this, + ); + OverlayUtil.showPositionedCard( + context: context, + cardToShow: entry, + transformTargetId: selectedToken!.text.uniqueKey, + closePrevOverlay: false, + backDropToDismiss: false, + addBorder: false, + overlayKey: selectedToken!.text.uniqueKey, + maxHeight: AppConfig.toolbarMaxHeight, + maxWidth: AppConfig.toolbarMinWidth, + ); } setState(() {}); } - void updateToolbarMode(MessageMode mode, [String? feature]) => setState(() { - debugger( - when: kDebugMode && - selectedToken == null && - [ - MessageMode.wordMorph, - MessageMode.wordZoom, - MessageMode.wordEmoji, - MessageMode.wordZoom, - ].contains(mode), - ); - - if (toolbarMode == mode) { - if (selectedToken == null) { - toolbarMode = MessageMode.noneSelected; - selectedMorphFeature = null; - } else if (mode != MessageMode.wordMorph) { - debugPrint('toolbarMode == mode: setting toolbarMode to wordZoom'); - toolbarMode = MessageMode.wordZoom; - } else { - if (selectedMorphFeature != feature) { - selectedMorphFeature = feature; - } else { - selectedMorphFeature = null; - toolbarMode = MessageMode.wordZoom; - } - } - } else { - switch (mode) { - case MessageMode.noneSelected: - selectedMorphFeature = null; - break; - case MessageMode.wordMorph: - selectedMorphFeature = - feature ?? selectedToken?.nextMorphFeatureEligibleForActivity; - if (selectedMorphFeature == null) { - updateToolbarMode(MessageMode.wordZoom); - return; - } - break; - case MessageMode.wordZoom: - selectedMorphFeature = null; - break; - case MessageMode.wordEmoji: - selectedMorphFeature = null; - break; - case MessageMode.wordMeaning: - selectedMorphFeature = null; - break; - case MessageMode.practiceActivity: - break; - case MessageMode.messageTextToSpeech: - if (isPlayingAudio) { - //TODO stop audio - } else { - // Play audio - } - break; - case MessageMode.messageSpeechToText: - _selectedSpan = null; - break; - case MessageMode.messageTranslation: - _selectedSpan = null; - break; - case MessageMode.messageMeaning: - _selectedSpan = null; - break; - } - - toolbarMode = mode; - if (mode != MessageMode.messageTextToSpeech) { - _highlightedTokens = null; - } - } + void updateToolbarMode(MessageMode mode) => setState(() { + selectedChoice = null; + toolbarMode = mode; }); + void onChoiceSelect(ConstructForm choice, [bool force = false]) { + if (selectedChoice == choice && !force) { + selectedChoice = null; + } else { + selectedChoice = choice; + } + + setState(() {}); + } + + void onMorphActivitySelect(PangeaToken token, MorphFeaturesEnum morph) { + if (toolbarMode != MessageMode.wordMorph) { + updateToolbarMode(MessageMode.wordMorph); + } + selectedMorph = morph; + _updateSelectedSpan(token.text, true); + } + + // only used for word meaning, emoji, and word focus listening atm + Future onMatchAttempt( + PangeaToken token, + ConstructForm choice, + ) async { + final ActivityTypeEnum activityType = toolbarMode.associatedActivityType!; + + final bool isCorrect = token.vocabConstructID == choice.cId; + + final ConstructUseTypeEnum? useType = + isCorrect ? activityType.correctUse : activityType.incorrectUse; + + if (useType != null) { + MatrixState.pangeaController.putAnalytics.setState( + AnalyticsStream( + eventId: pangeaMessageEvent!.eventId, + roomId: pangeaMessageEvent!.room.id, + constructs: [ + OneConstructUse( + useType: useType, + lemma: token.vocabConstructID.lemma, + constructType: ConstructTypeEnum.vocab, + metadata: ConstructUseMetaData( + roomId: pangeaMessageEvent!.room.id, + timeStamp: DateTime.now(), + eventId: pangeaMessageEvent!.eventId, + ), + category: token.vocabConstructID.category, + form: token.text.content, + ), + ], + origin: AnalyticsUpdateOrigin.wordZoom, + ), + ); + } + + feedbackStates.add(MatchFeedback(form: choice, isCorrect: isCorrect)); + + selectedChoice = null; + + setState(() => {}); + + await Future.delayed( + const Duration(milliseconds: 2000), + ); + + if (isCorrect) { + messageAnalyticsEntry?.onActivityComplete(activityType, token); + } + + feedbackStates.removeWhere((e) => e.form == choice); + + setState(() => {}); + } + ///////////////////////////////////// /// Getters //////////////////////////////////// @@ -363,9 +401,43 @@ class MessageOverlayController extends State (pangeaMessageEvent?.proportionOfActivitiesCompleted ?? 1) >= 1 || !messageInUserL2; + // bool get isEmojiDone => + // pangeaMessageEvent?.messageDisplayRepresentation?.tokensToSave + // .every((token) => token.vocabConstructID.userSetEmoji.isNotEmpty) ?? + // false; + + bool get isEmojiDone => + messageAnalyticsEntry?.activities(ActivityTypeEnum.emoji).isEmpty == true; + + bool get isMeaningDone => + messageAnalyticsEntry?.activities(ActivityTypeEnum.wordMeaning).isEmpty == + true; + + bool get isListeningDone => + messageAnalyticsEntry + ?.activities(ActivityTypeEnum.wordFocusListening) + .isEmpty == + true; + + bool get isMorphDone => + messageAnalyticsEntry?.activities(ActivityTypeEnum.morphId).isEmpty == + true; + + /// you have to complete one of the mode mini-games to unlock translation + bool get isTranslationUnlocked => + !messageInUserL2 || + (messageLemmaInfos?.isEmpty ?? false) || + isEmojiDone || + isMeaningDone || + isListeningDone || + isMorphDone; + + bool get isTotallyDone => + isEmojiDone && isMeaningDone && isListeningDone && isMorphDone; + MessageAnalyticsEntry? get messageAnalyticsEntry => pangeaMessageEvent?.messageDisplayRepresentation?.tokens != null - ? MatrixState.pangeaController.getAnalytics.perMessage.get( + ? MessageAnalyticsController.get( pangeaMessageEvent!.messageDisplayRepresentation!.tokens!, pangeaMessageEvent!, ) @@ -375,6 +447,15 @@ class MessageOverlayController extends State pangeaMessageEvent?.messageDisplayLangCode == MatrixState.pangeaController.languageController.userL2?.langCode; + PangeaToken? get selectedToken => + pangeaMessageEvent?.messageDisplayRepresentation?.tokens + ?.firstWhereOrNull(isTokenSelected); + + /// Whether the overlay is currently displaying a selection + bool get isSelection => _selectedSpan != null || _highlightedTokens != null; + + PangeaTokenText? get selectedSpan => _selectedSpan; + /////////////////////////////////// /// User action handlers ///////////////////////////////////// @@ -394,27 +475,27 @@ class MessageOverlayController extends State } } - void onNextActivityRequest() { - if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: "Tokens are null in onNextActivityRequest", - data: {}, - ); - return; - } + // // void onNextActivityRequest() { + // // if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens == null) { + // // debugger(when: kDebugMode); + // // ErrorHandler.logError( + // // e: "Tokens are null in onNextActivityRequest", + // // data: {}, + // // ); + // // return; + // // } - for (final token in pangeaMessageEvent! - .messageDisplayRepresentation!.tokens! - .where((t) => t.lemma.saveVocab)) { - final MessageMode nextActivityMode = token.modeForToken; - if (nextActivityMode != MessageMode.wordZoom) { - _selectedSpan = token.text; - _updateSelectedSpan(token.text, nextActivityMode); - return; - } - } - } + // // for (final token in pangeaMessageEvent! + // // .messageDisplayRepresentation!.tokens! + // // .where((t) => t.lemma.saveVocab)) { + // // final MessageMode nextActivityMode = token.modeForToken; + // // if (nextActivityMode != MessageMode.wordZoom) { + // // _selectedSpan = token.text; + // // _updateSelectedSpan(token.text); + // // return; + // // } + // // } + // // } /////////////////////////////////// /// Functions @@ -460,14 +541,14 @@ class MessageOverlayController extends State /// When an activity is completed, we need to update the state /// and check if the toolbar should be unlocked - void onActivityFinish(ActivityTypeEnum activityType) { - messageAnalyticsEntry!.onActivityComplete(); + void onActivityFinish(ActivityTypeEnum activityType, PangeaToken? token) { + messageAnalyticsEntry!.onActivityComplete(activityType, null); if (selectedToken == null) { updateToolbarMode(MessageMode.noneSelected); } - updateToolbarMode(selectedToken!.modeForToken); + // updateToolbarMode(selectedToken!.modeForToken); if (!mounted) return; setState(() {}); @@ -483,14 +564,44 @@ class MessageOverlayController extends State void onClickOverlayMessageToken( PangeaToken token, ) { - if (toolbarMode == MessageMode.practiceActivity && - messageAnalyticsEntry?.nextActivity?.activityType == - ActivityTypeEnum.hiddenWordListening) { + if (messageAnalyticsEntry?.hasHiddenWordActivity == true) { return; } + /// we don't want to associate the audio with the text in this mode + if (messageAnalyticsEntry?.hasActivity( + ActivityTypeEnum.wordFocusListening, + token, + ) == + false) { + widget.chatController.choreographer.tts.tryToSpeak( + token.text.content, + context, + targetID: null, + ); + } + _updateSelectedSpan(token.text); - setState(() {}); + } + + void onDragTargetClick(PangeaToken token) { + if (toolbarMode.associatedActivityType == null) { + debugger(when: kDebugMode); + return; + } + + // supporting highlighting of the target then a click on an option is possible + // however, a user might want to click an option to hear the audio then + // accidentally submit. for this reason, holding off on supporting that flow + // to see if it feels needed + if (selectedChoice == null) { + return; + } + + onMatchAttempt( + token, + selectedChoice!, + ); } /// Whether the given token is currently selected or highlighted diff --git a/lib/pangea/toolbar/widgets/message_selection_positioner.dart b/lib/pangea/toolbar/widgets/message_selection_positioner.dart index 7a3ddc66d..8506914c4 100644 --- a/lib/pangea/toolbar/widgets/message_selection_positioner.dart +++ b/lib/pangea/toolbar/widgets/message_selection_positioner.dart @@ -13,11 +13,13 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/overlay_footer.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/overlay_center_content.dart'; import 'package:fluffychat/pangea/toolbar/widgets/overlay_header.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_and_progress_row.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_column.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -51,20 +53,34 @@ class MessageSelectionPositioner extends StatefulWidget { class MessageSelectionPositionerState extends State with TickerProviderStateMixin { late AnimationController _animationController; - Animation? _overlayPositionAnimation; + Animation? _overlayOffsetAnimation; + Animation? _buttonsOffsetAnimation; + Animation? _messageSizeAnimation; StreamSubscription? _reactionSubscription; /// if the message height is too tall to fit with the tools, adjust the message height double? _adjustedMessageHeight; + Offset? _centeredMessageOffset; + Size? _centeredMessageSize; + final Completer _centeredMessageCompleter = Completer(); + + Offset? _centeredButtonsOffset; + Size? _centeredButtonsSize; + final Completer _centeredButtonsCompleter = Completer(); + + bool _finishedAnimation = false; + @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, - duration: - const Duration(milliseconds: AppConfig.overlayAnimationDuration), + duration: const Duration( + milliseconds: AppConfig.overlayAnimationDuration, + // seconds: 5, + ), ); _reactionSubscription = @@ -86,27 +102,72 @@ class MessageSelectionPositionerState extends State ); }, ).listen((_) => setState(() {})); + + Future.wait([ + _centeredMessageCompleter.future, + _centeredButtonsCompleter.future, + ]).then((_) => _startAnimation()); + } + + @override + void didUpdateWidget(MessageSelectionPositioner oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.overlayController.toolbarMode != + widget.overlayController.toolbarMode) { + setState(() {}); + } } @override void didChangeDependencies() { super.didChangeDependencies(); + } + + @override + void dispose() { + _animationController.dispose(); + _reactionSubscription?.cancel(); + super.dispose(); + } + + void _setCenteredMessageSize(RenderBox renderBox) { + if (_finishedAnimation) return; + _centeredMessageSize = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + _centeredMessageOffset = Offset( + offset.dx - _columnWidth - _horizontalPadding - 2.0, + _mediaQuery!.size.height - + offset.dy - + renderBox.size.height - + _reactionsHeight, + ); + setState(() {}); + + if (!_centeredMessageCompleter.isCompleted) { + _centeredMessageCompleter.complete(); + } + } + + void _setCenteredButtonsSize(RenderBox renderBox) { + if (_finishedAnimation) return; + _centeredButtonsSize = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + _centeredButtonsOffset = Offset( + offset.dx - _columnWidth - _horizontalPadding - 2.0, + offset.dy, + ); + setState(() {}); + + if (!_centeredButtonsCompleter.isCompleted) { + _centeredButtonsCompleter.complete(); + } + } + + void _startAnimation() { if (_mediaQuery == null) { return; } - final bool hasHeaderOverflow = - _remainingTopSpace < AppConfig.toolbarSpacing; - final bool hasFooterOverflow = - _remainingBottomSpace < AppConfig.toolbarSpacing; - - if (!hasHeaderOverflow && !hasFooterOverflow || _mediaQuery == null) { - return; - } - - double adjustedBottomOffset = _totalToolbarBottomOffset; - double scrollOffset = 0; - // if the message height is too tall to fit, adjust the message height if (_totalVerticalSpace! < _maxTotalToolbarHeight) { _adjustedMessageHeight = _totalVerticalSpace! - @@ -117,22 +178,13 @@ class MessageSelectionPositionerState extends State _adjustedMessageHeight = max(_adjustedMessageHeight!, 0); } - // if the overlay could have header overflow if the message wasn't shifted, we want to shift - // it down so the bottom to give it enough space. - if (hasHeaderOverflow) { - // what is the distance between the current top offset of the toolbar and the desired top offset? - final double neededShift = - (_headerHeight - _totalToolbarTopOffset) + AppConfig.toolbarSpacing; - adjustedBottomOffset = _totalToolbarBottomOffset - neededShift; - } else if (hasFooterOverflow) { - adjustedBottomOffset = _footerHeight + AppConfig.toolbarSpacing; - } - - scrollOffset = adjustedBottomOffset - _totalToolbarBottomOffset; - - _overlayPositionAnimation = Tween( - begin: _totalToolbarBottomOffset, - end: adjustedBottomOffset, + _overlayOffsetAnimation = Tween( + begin: Offset( + _ownMessage ? _messageRightOffset : _messageLeftOffset, + _totalToolbarBottomOffset, + ), + // For own messages, dx is the right offset. For other's messages, dx is the left offset. + end: _centeredMessageOffset, ).animate( CurvedAnimation( parent: _animationController, @@ -140,20 +192,48 @@ class MessageSelectionPositionerState extends State ), ); - widget.chatController.scrollController.animateTo( - widget.chatController.scrollController.offset - scrollOffset, - duration: - const Duration(milliseconds: AppConfig.overlayAnimationDuration), - curve: FluffyThemes.animationCurve, + _buttonsOffsetAnimation = Tween( + begin: Offset( + _ownMessage + ? (_centeredButtonsSize!.width * -1) + : _centeredButtonsSize!.width + _mediaQuery!.size.width, + _totalToolbarBottomOffset, + ), + end: _centeredButtonsOffset, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: FluffyThemes.animationCurve, + ), ); - _animationController.forward(); - } - @override - void dispose() { - _animationController.dispose(); - _reactionSubscription?.cancel(); - super.dispose(); + _messageSizeAnimation = Tween( + begin: Size( + _messageSize.width, + _messageHeight, + ), + end: _centeredMessageSize, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: FluffyThemes.animationCurve, + ), + ); + + // _contentSizeAnimation = Tween( + // begin: 0, + // end: 1, + // ).animate( + // CurvedAnimation( + // parent: _animationController, + // curve: FluffyThemes.animationCurve, + // ), + // ); + + _animationController.forward().then((_) { + _finishedAnimation = true; + if (mounted) setState(() {}); + }); } T _runWithLogging( @@ -175,7 +255,7 @@ class MessageSelectionPositionerState extends State } } - bool get showDetails => + bool get _showDetails => (Matrix.of(context).store.getBool(SettingKeys.displayChatDetailsColumn) ?? false) && FluffyThemes.isThreeColumnMode(context) && @@ -221,7 +301,7 @@ class MessageSelectionPositionerState extends State //TODO: figure out where the 16 and 8 come from and use references instead of hard-coded values static const _messageDefaultLeftMargin = Avatar.defaultSize + 16 + 8; - double get _messageMaxWidth { + double get _toolbarMaxWidth { final double messageMargin = widget.event.isActivityMessage ? 0 : Avatar.defaultSize + 16 + 8; final bool showingDetails = widget.chatController.displayChatDetailsColumn; @@ -257,21 +337,16 @@ class MessageSelectionPositionerState extends State ); } - double get _messageTopOffset { - return _messageOffset.dy - - (_mediaQuery?.padding.top ?? 0) + - (_mediaQuery?.viewPadding.top ?? 0); - } - double get _messageBottomOffset => _mediaQuery!.size.height - _messageOffset.dy - _messageHeight; - double get _messageLeftOffset => - _messageOffset.dx - _columnWidth - _horizontalPadding; + double get _messageLeftOffset => max( + _messageOffset.dx - _columnWidth - _horizontalPadding, + 0, + ); double get _messageRightOffset { - if (_mediaQuery == null || - widget.event.senderId != widget.event.room.client.userID) { + if (_mediaQuery == null || !_ownMessage) { return 0; } return _mediaQuery!.size.width - @@ -327,28 +402,13 @@ class MessageSelectionPositionerState extends State AppConfig.toolbarSpacing + AppConfig.toolbarMaxHeight; - double get _maxTotalToolbarHeight { - return max(AppConfig.toolbarButtonsColumnHeight, _maxTotalCenterHeight); - } - - double get _totalToolbarTopOffset { - final topOffset = _messageTopOffset - - (AppConfig.toolbarSpacing + AppConfig.toolbarMaxHeight); - final addedColumnOffset = - max(AppConfig.toolbarButtonsColumnHeight - _maxTotalCenterHeight, 0); - return topOffset - addedColumnOffset; - } + double get _maxTotalToolbarHeight => _maxTotalCenterHeight; double get _totalToolbarBottomOffset => _messageBottomOffset - _reactionsHeight; - /// The remaining space between the top of the screen and the top of the toolbar. - /// Negative if the toolbar is overflowing the top of the screen. - double get _remainingTopSpace => _totalToolbarTopOffset - _headerHeight; - - /// The remaining space between the bottom of the screen and the bottom of the toolbar. - /// Negative if the toolbar is overflowing the bottom of the screen. - double get _remainingBottomSpace => _totalToolbarBottomOffset - _footerHeight; + bool get _ownMessage => + widget.event.senderId == widget.event.room.client.userID; @override Widget build(BuildContext context) { @@ -362,58 +422,75 @@ class MessageSelectionPositionerState extends State right: _horizontalPadding, ), child: Stack( + alignment: Alignment.center, children: [ - AnimatedBuilder( - animation: _overlayPositionAnimation ?? _animationController, - builder: (context, child) { - return Positioned( - // subtract width of - left: widget.event.senderId != widget.event.room.client.userID - ? max( - _messageLeftOffset - - AppConfig.toolbarButtonsColumnWidth, - 0, - ) - : null, - right: widget.event.senderId == widget.event.room.client.userID - ? _messageRightOffset - : _messageRightOffset, - bottom: _overlayPositionAnimation?.value ?? - _totalToolbarBottomOffset, - child: Row( - mainAxisAlignment: - widget.event.senderId == widget.event.room.client.userID - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // fixed height and width container - - ToolbarButtonAndProgressColumn( - event: widget.event, - overlayController: widget.overlayController, - shouldShowToolbarButtons: showToolbarButtons, - width: AppConfig.toolbarButtonsColumnWidth, - height: AppConfig.toolbarButtonsColumnHeight, - ), - OverlayCenterContent( - messageHeight: _messageHeight, - messageWidth: _messageSize.width, - maxWidth: _messageMaxWidth, - event: widget.event, - pangeaMessageEvent: widget.pangeaMessageEvent, - nextEvent: widget.nextEvent, - prevEvent: widget.prevEvent, - overlayController: widget.overlayController, - chatController: widget.chatController, - hasReactions: _hasReactions, - shouldShowToolbarButtons: showToolbarButtons, - ), - ], - ), - ); - }, + Opacity( + opacity: _finishedAnimation ? 1.0 : 0.0, + child: OverlayCenterContent( + event: widget.event, + messageHeight: _messageHeight, + messageWidth: _messageSize.width, + toolbarMaxWidth: _toolbarMaxWidth, + overlayController: widget.overlayController, + chatController: widget.chatController, + pangeaMessageEvent: widget.pangeaMessageEvent, + nextEvent: widget.nextEvent, + prevEvent: widget.prevEvent, + showToolbarButtons: showToolbarButtons, + hasReactions: _hasReactions, + onChangeMessageSize: _setCenteredMessageSize, + onChangeButtonsSize: _setCenteredButtonsSize, + isTransitionAnimation: false, + ), ), + if (!_finishedAnimation) + AnimatedBuilder( + animation: _overlayOffsetAnimation ?? _animationController, + builder: (context, child) { + return Positioned( + left: _ownMessage + ? null + : (_overlayOffsetAnimation?.value)?.dx ?? + _messageLeftOffset, + right: _ownMessage + ? (_overlayOffsetAnimation?.value)?.dx ?? + _messageRightOffset + : null, + bottom: (_overlayOffsetAnimation?.value)?.dy ?? + _totalToolbarBottomOffset, + child: OverlayCenterContent( + event: widget.event, + messageHeight: _messageHeight, + messageWidth: _messageSize.width, + toolbarMaxWidth: _toolbarMaxWidth, + overlayController: widget.overlayController, + chatController: widget.chatController, + pangeaMessageEvent: widget.pangeaMessageEvent, + nextEvent: widget.nextEvent, + prevEvent: widget.prevEvent, + showToolbarButtons: showToolbarButtons, + hasReactions: _hasReactions, + sizeAnimation: _messageSizeAnimation, + isTransitionAnimation: true, + ), + ); + }, + ), + if (!_finishedAnimation && _buttonsOffsetAnimation != null) + AnimatedBuilder( + animation: _buttonsOffsetAnimation!, + builder: (context, child) { + return Positioned( + left: (_buttonsOffsetAnimation?.value)!.dx, + top: _centeredButtonsOffset?.dy, + child: ToolbarButtonRow( + event: widget.event, + overlayController: widget.overlayController, + shouldShowToolbarButtons: showToolbarButtons, + ), + ); + }, + ), Align( alignment: Alignment.bottomCenter, child: Row( @@ -423,6 +500,11 @@ class MessageSelectionPositionerState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ + // ToolbarButtonRow( + // event: widget.overlayController.pangeaMessageEvent!.event, + // overlayController: widget.overlayController, + // shouldShowToolbarButtons: showToolbarButtons, + // ), OverlayFooter( controller: widget.chatController, overlayController: widget.overlayController, @@ -431,7 +513,7 @@ class MessageSelectionPositionerState extends State ], ), ), - if (showDetails) + if (_showDetails) const SizedBox( width: FluffyThemes.columnWidth, ), @@ -444,6 +526,18 @@ class MessageSelectionPositionerState extends State children: [ SizedBox(height: _mediaQuery?.padding.top ?? 0), OverlayHeader(controller: widget.chatController), + widget.overlayController.toolbarMode.instructionsEnum != null + ? Container( + constraints: const BoxConstraints( + maxWidth: 400, + ), + child: InstructionsInlineTooltip( + instructionsEnum: widget + .overlayController.toolbarMode.instructionsEnum!, + bold: true, + ), + ) + : const SizedBox.shrink(), ], ), ), diff --git a/lib/pangea/toolbar/widgets/message_token_text.dart b/lib/pangea/toolbar/widgets/message_token_text.dart index 5903e35e6..59b63c9fb 100644 --- a/lib/pangea/toolbar/widgets/message_token_text.dart +++ b/lib/pangea/toolbar/widgets/message_token_text.dart @@ -1,25 +1,32 @@ -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; - import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/utils/message_text_util.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/message_token_text/message_token_button.dart'; +import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; /// Question - does this need to be stateful or does this work? /// Need to test. +/// + class MessageTokenText extends StatelessWidget { final PangeaMessageEvent _pangeaMessageEvent; final TextStyle _style; final bool Function(PangeaToken)? _isSelected; final void Function(PangeaToken)? _onClick; + final bool Function(PangeaToken)? _isHighlighted; + final MessageMode? _messageMode; + final MessageOverlayController? _overlayController; + final bool _isTransitionAnimation; const MessageTokenText({ super.key, @@ -28,16 +35,24 @@ class MessageTokenText extends StatelessWidget { required TextStyle style, required void Function(PangeaToken)? onClick, bool Function(PangeaToken)? isSelected, + bool Function(PangeaToken)? isHighlighted, + MessageMode? messageMode, + MessageOverlayController? overlayController, + bool isTransitionAnimation = false, }) : _onClick = onClick, _isSelected = isSelected, _style = style, - _pangeaMessageEvent = pangeaMessageEvent; + _pangeaMessageEvent = pangeaMessageEvent, + _messageMode = messageMode, + _isHighlighted = isHighlighted, + _overlayController = overlayController, + _isTransitionAnimation = isTransitionAnimation; List? get _tokens => _pangeaMessageEvent.messageDisplayRepresentation?.tokens; MessageAnalyticsEntry? get messageAnalyticsEntry => _tokens != null - ? MatrixState.pangeaController.getAnalytics.perMessage.get( + ? MessageAnalyticsController.get( _tokens!, _pangeaMessageEvent, ) @@ -60,42 +75,18 @@ class MessageTokenText extends StatelessWidget { return MessageTextWidget( pangeaMessageEvent: _pangeaMessageEvent, - style: _style, + existingStyle: _style, messageAnalyticsEntry: messageAnalyticsEntry, isSelected: _isSelected, onClick: callOnClick, + messageMode: _messageMode, + isHighlighted: _isHighlighted, + overlayController: _overlayController, + isTransitionAnimation: _isTransitionAnimation, ); } } -class TokenPosition { - /// Start index of the full substring in the message - final int start; - - /// End index of the full substring in the message - final int end; - - /// Start index of the token in the message - final int tokenStart; - - /// End index of the token in the message - final int tokenEnd; - - final bool selected; - final bool hideContent; - final PangeaToken? token; - - const TokenPosition({ - required this.start, - required this.end, - required this.tokenStart, - required this.tokenEnd, - required this.hideContent, - required this.selected, - this.token, - }); -} - class HiddenText extends StatelessWidget { final String text; final TextStyle style; @@ -143,27 +134,72 @@ class HiddenText extends StatelessWidget { class MessageTextWidget extends StatelessWidget { final PangeaMessageEvent pangeaMessageEvent; - final TextStyle style; + final TextStyle existingStyle; final MessageAnalyticsEntry? messageAnalyticsEntry; final bool Function(PangeaToken)? isSelected; final void Function(TokenPosition tokenPosition)? onClick; + final bool Function(PangeaToken)? isHighlighted; final bool? softWrap; final int? maxLines; final TextOverflow? overflow; + final MessageMode? messageMode; + + final Animation? contentSizeAnimation; + final MessageOverlayController? overlayController; + final bool isTransitionAnimation; const MessageTextWidget({ super.key, required this.pangeaMessageEvent, - required this.style, + required this.existingStyle, this.messageAnalyticsEntry, this.isSelected, this.onClick, this.softWrap, this.maxLines, this.overflow, + this.messageMode, + this.isHighlighted, + this.contentSizeAnimation, + this.overlayController, + this.isTransitionAnimation = false, }); + TextStyle get style => overlayController != null + ? existingStyle.copyWith( + fontSize: 22, + ) + : existingStyle; + + /// for some reason, this isn't the same as tokenTextWidth + double tokenTextWidthForContainer(PangeaToken token) { + final textPainter = TextPainter( + text: TextSpan(text: token.text.content, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(); + return textPainter.width; + } + + Color backgroundColor(TokenPosition tokenPosition) { + final hideTokenHighlights = messageAnalyticsEntry != null && + (messageAnalyticsEntry!.hasHiddenWordActivity || + messageAnalyticsEntry!.hasMessageMeaningActivity); + + Color backgroundColor = Colors.transparent; + + if (!hideTokenHighlights) { + if (tokenPosition.selected) { + backgroundColor = AppConfig.primaryColor; + } + // else if (tokenPosition.isHighlighted) { + // backgroundColor = AppConfig.success.withAlpha(80); + // } + } + return backgroundColor; + } + @override Widget build(BuildContext context) { final Characters messageCharacters = @@ -173,6 +209,7 @@ class MessageTextWidget extends StatelessWidget { pangeaMessageEvent, messageAnalyticsEntry: messageAnalyticsEntry, isSelected: isSelected, + isHighlighted: isHighlighted, ); if (tokenPositions == null) { @@ -185,10 +222,6 @@ class MessageTextWidget extends StatelessWidget { ); } - final hideTokenHighlights = messageAnalyticsEntry != null && - (messageAnalyticsEntry!.hasHiddenWordActivity || - messageAnalyticsEntry!.hasMessageMeaningActivity); - final theme = Theme.of(context); final ownMessage = pangeaMessageEvent.senderId == Matrix.of(context).client.userID; @@ -205,24 +238,13 @@ class MessageTextWidget extends StatelessWidget { text: TextSpan( children: tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) { - final shouldDo = pangeaMessageEvent.shouldDoActivity( - token: tokenPosition.token, - a: ActivityTypeEnum.wordMeaning, - feature: null, - tag: null, - ); - - final didMeaningActivity = - tokenPosition.token?.didActivitySuccessfully( - ActivityTypeEnum.wordMeaning, - ) ?? - true; - final substring = messageCharacters .skip(tokenPosition.start) .take(tokenPosition.end - tokenPosition.start) .toString(); +<<<<<<< HEAD +======= Color backgroundColor = Colors.transparent; if (!hideTokenHighlights) { if (tokenPosition.selected) { @@ -235,6 +257,7 @@ class MessageTextWidget extends StatelessWidget { } } +>>>>>>> main if (tokenPosition.token?.pos == 'SPACE') { return const TextSpan(text: '\n'); } @@ -257,68 +280,121 @@ class MessageTextWidget extends StatelessWidget { .take(endSplitIndex - startSplitIndex) .toString(); + final token = tokenPosition.token!; + + final tokenWidth = tokenTextWidthForContainer(token); + return WidgetSpan( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: onClick != null - ? () => onClick?.call(tokenPosition) - : null, - child: RichText( - text: TextSpan( - children: [ - if (start.isNotEmpty) - LinkifySpan( - text: start, - style: style, - linkStyle: TextStyle( - decoration: TextDecoration.underline, - color: linkColor, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - tokenPosition.hideContent - ? WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: GestureDetector( - onTap: onClick != null - ? () => onClick?.call(tokenPosition) - : null, - child: HiddenText( - text: middle, - style: style, - ), - ), + child: CompositedTransformTarget( + link: overlayController == null || isTransitionAnimation + ? LayerLinkAndKey(token.hashCode.toString()).link + : MatrixState.pAnyState + .layerLinkAndKey(token.text.uniqueKey) + .link, + child: Column( + key: overlayController == null || isTransitionAnimation + ? null + : MatrixState.pAnyState + .layerLinkAndKey(token.text.uniqueKey) + .key, + children: [ + MessageTokenButton( + token: token, + overlayController: overlayController, + textStyle: style, + width: tokenWidth, + animate: isTransitionAnimation, + activity: overlayController + ?.toolbarMode.associatedActivityType != + null + ? overlayController?.messageAnalyticsEntry + ?.activities( + overlayController! + .toolbarMode.associatedActivityType!, ) - : LinkifySpan( - text: middle, - style: style.merge( - TextStyle( - backgroundColor: backgroundColor, - ), - ), - linkStyle: TextStyle( - decoration: TextDecoration.underline, - color: linkColor, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - if (end.isNotEmpty) - LinkifySpan( - text: end, - style: style, - linkStyle: TextStyle( - decoration: TextDecoration.underline, - color: linkColor, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - ], + .firstWhereOrNull((a) => a.tokens.contains(token)) + : null, ), - ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onClick != null + ? () => onClick?.call(tokenPosition) + : null, + child: RichText( + text: TextSpan( + children: [ + if (start.isNotEmpty) + LinkifySpan( + text: start, + style: style, + linkStyle: TextStyle( + decoration: TextDecoration.underline, + color: linkColor, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), + ), + tokenPosition.hideContent + ? WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: GestureDetector( + onTap: onClick != null + ? () => onClick?.call(tokenPosition) + : null, + child: HiddenText( + text: middle, + style: style, + ), + ), + ) + : LinkifySpan( + text: middle, + // style: style.merge( + // TextStyle( + // backgroundColor: backgroundColor(tokenPosition) + // ), + // ), + style: style, + linkStyle: TextStyle( + decoration: TextDecoration.underline, + color: linkColor, + ), + onOpen: (url) => + UrlLauncher(context, url.url) + .launchUrl(), + ), + if (end.isNotEmpty) + LinkifySpan( + text: end, + style: style, + linkStyle: TextStyle( + decoration: TextDecoration.underline, + color: linkColor, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), + ), + ], + ), + ), + ), + ), + AnimatedContainer( + duration: const Duration( + milliseconds: AppConfig.overlayAnimationDuration, + ), + height: + overlayController != null && !isTransitionAnimation + ? 4 + : 0, + width: tokenWidth, + child: Container( + color: backgroundColor(tokenPosition), + ), + ), + ], ), ), ); diff --git a/lib/pangea/toolbar/widgets/message_translation_card.dart b/lib/pangea/toolbar/widgets/message_translation_card.dart index 16697948b..095600a47 100644 --- a/lib/pangea/toolbar/widgets/message_translation_card.dart +++ b/lib/pangea/toolbar/widgets/message_translation_card.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -9,6 +7,7 @@ import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class MessageTranslationCard extends StatefulWidget { final PangeaMessageEvent messageEvent; @@ -28,7 +27,6 @@ class MessageTranslationCardState extends State { @override void initState() { - debugPrint('MessageTranslationCard initState'); super.initState(); loadTranslation(); } @@ -99,42 +97,24 @@ class MessageTranslationCardState extends State { return const ToolbarContentLoadingIndicator(); } - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppConfig.toolbarMinWidth, - maxHeight: AppConfig.toolbarMaxHeight, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 20, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - repEvent!.text, - style: AppConfig.messageTextStyle( - widget.messageEvent.event, - Theme.of(context).colorScheme.primary, - ), - textAlign: TextAlign.center, - ), - if (notGoingToTranslate && - !InstructionsEnum.l1Translation.isToggledOff) - const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: InstructionsInlineTooltip( - instructionsEnum: InstructionsEnum.l1Translation, - ), - ), - ], - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + repEvent!.text, + style: AppConfig.messageTextStyle( + widget.messageEvent.event, + Theme.of(context).colorScheme.primary, ), + textAlign: TextAlign.center, ), - ), + if (notGoingToTranslate) + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.l1Translation, + ), + ], ); } } diff --git a/lib/pangea/toolbar/widgets/overlay_center_content.dart b/lib/pangea/toolbar/widgets/overlay_center_content.dart index 17d1d0ecd..cd7b936dd 100644 --- a/lib/pangea/toolbar/widgets/overlay_center_content.dart +++ b/lib/pangea/toolbar/widgets/overlay_center_content.dart @@ -2,93 +2,121 @@ import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/measure_render_box.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_toolbar.dart'; import 'package:fluffychat/pangea/toolbar/widgets/overlay_message.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_column.dart'; class OverlayCenterContent extends StatelessWidget { - final double messageHeight; - final double messageWidth; - final double maxWidth; - final Event event; final Event? nextEvent; final Event? prevEvent; - - final bool hasReactions; - final bool shouldShowToolbarButtons; - final PangeaMessageEvent? pangeaMessageEvent; + final MessageOverlayController overlayController; final ChatController chatController; + final Animation? sizeAnimation; + final void Function(RenderBox)? onChangeMessageSize; + final void Function(RenderBox)? onChangeContentSize; + final void Function(RenderBox)? onChangeButtonsSize; + + final double messageHeight; + final double messageWidth; + final double toolbarMaxWidth; + + final bool showToolbarButtons; + final bool hasReactions; + final bool isTransitionAnimation; + const OverlayCenterContent({ - super.key, + required this.event, required this.messageHeight, required this.messageWidth, - required this.maxWidth, - required this.event, + required this.toolbarMaxWidth, required this.overlayController, required this.chatController, + required this.pangeaMessageEvent, + required this.nextEvent, + required this.prevEvent, + required this.showToolbarButtons, required this.hasReactions, - required this.shouldShowToolbarButtons, - this.pangeaMessageEvent, - this.nextEvent, - this.prevEvent, + this.onChangeMessageSize, + this.onChangeContentSize, + this.onChangeButtonsSize, + this.sizeAnimation, + this.isTransitionAnimation = false, + super.key, }); @override Widget build(BuildContext context) { - return Container( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Material( - type: MaterialType.transparency, - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: event.senderId == event.room.client.userID - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - if (pangeaMessageEvent != null && - pangeaMessageEvent!.shouldShowToolbar) - ReadingAssistanceContentCard( - pangeaMessageEvent: pangeaMessageEvent!, - overlayController: overlayController, - ), - const SizedBox(height: AppConfig.toolbarSpacing), - SizedBox( - height: messageHeight, - child: OverlayMessage( - event, - pangeaMessageEvent: pangeaMessageEvent, - immersionMode: chatController.choreographer.immersionMode, - controller: chatController, - overlayController: overlayController, - nextEvent: nextEvent, - prevEvent: prevEvent, - timeline: chatController.timeline!, - messageWidth: messageWidth, - messageHeight: messageHeight, - ), - ), - if (hasReactions) - Padding( - padding: const EdgeInsets.all(4), - child: SizedBox( - height: 20, - child: MessageReactions( + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + constraints: BoxConstraints(maxWidth: toolbarMaxWidth), + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: event.senderId == event.room.client.userID + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + MeasureRenderBox( + onChange: onChangeMessageSize, + child: OverlayMessage( event, - chatController.timeline!, + pangeaMessageEvent: pangeaMessageEvent, + immersionMode: chatController.choreographer.immersionMode, + controller: chatController, + overlayController: overlayController, + nextEvent: nextEvent, + prevEvent: prevEvent, + timeline: chatController.timeline!, + sizeAnimation: sizeAnimation, + // there's a split seconds between when the transition animation starts and + // when the sizeAnimation is set when the original dimensions need to be enforced + messageWidth: sizeAnimation == null && isTransitionAnimation + ? messageWidth + : null, + messageHeight: + sizeAnimation == null && isTransitionAnimation + ? messageHeight + : null, + isTransitionAnimation: isTransitionAnimation, ), ), - ), - ], + if (hasReactions) + Padding( + padding: const EdgeInsets.all(4), + child: SizedBox( + height: 20, + child: MessageReactions( + event, + chatController.timeline!, + ), + ), + ), + ], + ), + ), ), - ), + if (!isTransitionAnimation) + MeasureRenderBox( + onChange: onChangeButtonsSize, + child: ToolbarButtonRow( + event: event, + overlayController: overlayController, + shouldShowToolbarButtons: showToolbarButtons, + ), + ), + ], ); } } diff --git a/lib/pangea/toolbar/widgets/overlay_message.dart b/lib/pangea/toolbar/widgets/overlay_message.dart index 3da471de9..b0da3382b 100644 --- a/lib/pangea/toolbar/widgets/overlay_message.dart +++ b/lib/pangea/toolbar/widgets/overlay_message.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; @@ -22,8 +23,12 @@ class OverlayMessage extends StatelessWidget { final Event? prevEvent; final Timeline timeline; final bool immersionMode; - final double messageWidth; - final double messageHeight; + + final Animation? sizeAnimation; + final double? messageWidth; + final double? messageHeight; + + final bool isTransitionAnimation; const OverlayMessage( this.event, { @@ -36,6 +41,8 @@ class OverlayMessage extends StatelessWidget { this.pangeaMessageEvent, this.nextEvent, this.prevEvent, + this.sizeAnimation, + this.isTransitionAnimation = false, super.key, }); @@ -112,127 +119,143 @@ class OverlayMessage extends StatelessWidget { ? ThemeData.dark().colorScheme.onPrimary : theme.colorScheme.onSurface; + final content = SingleChildScrollView( + dragStartBehavior: DragStartBehavior.down, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + padding: noBubble || noPadding + ? EdgeInsets.zero + : const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + width: messageWidth, + height: messageHeight, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.relationshipType == RelationshipTypes.reply) + FutureBuilder( + future: event.getReplyEvent( + timeline, + ), + builder: ( + BuildContext context, + snapshot, + ) { + final replyEvent = snapshot.hasData + ? snapshot.data! + : Event( + eventId: event.relationshipEventId!, + content: { + 'msgtype': 'm.text', + 'body': '...', + }, + senderId: event.senderId, + type: 'm.room.message', + room: event.room, + status: EventStatus.sent, + originServerTs: DateTime.now(), + ); + return Padding( + padding: const EdgeInsets.only( + bottom: 4.0, + ), + child: InkWell( + borderRadius: ReplyContent.borderRadius, + onTap: () => controller.scrollToEventId( + replyEvent.eventId, + ), + child: AbsorbPointer( + child: ReplyContent( + replyEvent, + ownMessage: ownMessage, + timeline: timeline, + ), + ), + ), + ); + }, + ), + MessageContent( + event.getDisplayEvent(timeline), + textColor: textColor, + pangeaMessageEvent: pangeaMessageEvent, + immersionMode: immersionMode, + overlayController: overlayController, + controller: controller, + nextEvent: nextEvent, + prevEvent: prevEvent, + borderRadius: borderRadius, + timeline: timeline, + linkColor: theme.brightness == Brightness.light + ? theme.colorScheme.primary + : ownMessage + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + isTransitionAnimation: isTransitionAnimation, + ), + if (event.hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + )) + Padding( + padding: const EdgeInsets.only( + top: 4.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (event.hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + )) ...[ + Icon( + Icons.edit_outlined, + color: textColor.withAlpha(164), + size: 14, + ), + Text( + ' - ${displayEvent.originServerTs.localizedTimeShort(context)}', + style: TextStyle( + color: textColor.withAlpha( + 164, + ), + fontSize: 12, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + return Material( color: color, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: borderRadius, ), - child: SingleChildScrollView( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - ), - padding: noBubble || noPadding - ? EdgeInsets.zero - : const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - width: messageWidth, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (event.relationshipType == RelationshipTypes.reply) - FutureBuilder( - future: event.getReplyEvent( - timeline, - ), - builder: ( - BuildContext context, - snapshot, - ) { - final replyEvent = snapshot.hasData - ? snapshot.data! - : Event( - eventId: event.relationshipEventId!, - content: { - 'msgtype': 'm.text', - 'body': '...', - }, - senderId: event.senderId, - type: 'm.room.message', - room: event.room, - status: EventStatus.sent, - originServerTs: DateTime.now(), - ); - return Padding( - padding: const EdgeInsets.only( - bottom: 4.0, - ), - child: InkWell( - borderRadius: ReplyContent.borderRadius, - onTap: () => controller.scrollToEventId( - replyEvent.eventId, - ), - child: AbsorbPointer( - child: ReplyContent( - replyEvent, - ownMessage: ownMessage, - timeline: timeline, - ), - ), - ), - ); - }, - ), - MessageContent( - event.getDisplayEvent(timeline), - textColor: textColor, - pangeaMessageEvent: pangeaMessageEvent, - immersionMode: immersionMode, - overlayController: overlayController, - controller: controller, - nextEvent: nextEvent, - prevEvent: prevEvent, - borderRadius: borderRadius, - timeline: timeline, - linkColor: theme.brightness == Brightness.light - ? theme.colorScheme.primary - : ownMessage - ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, - ), - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.edit, - )) - Padding( - padding: const EdgeInsets.only( - top: 4.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.edit, - )) ...[ - Icon( - Icons.edit_outlined, - color: textColor.withAlpha(164), - size: 14, - ), - Text( - ' - ${displayEvent.originServerTs.localizedTimeShort(context)}', - style: TextStyle( - color: textColor.withAlpha( - 164, - ), - fontSize: 12, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), + child: sizeAnimation != null + ? AnimatedBuilder( + animation: sizeAnimation!, + builder: (context, child) { + return SizedBox( + height: sizeAnimation!.value.height, + width: sizeAnimation!.value.width, + child: content, + ); + }, + ) + : content, ); } } diff --git a/lib/pangea/toolbar/widgets/practice_activity/emoji_practice_button.dart b/lib/pangea/toolbar/widgets/practice_activity/emoji_practice_button.dart index ec108ff61..1ac4aeb10 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/emoji_practice_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/emoji_practice_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:fluffychat/pangea/emojis/emoji_stack.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart'; @@ -19,10 +20,10 @@ class EmojiPracticeButton extends StatelessWidget { Widget build(BuildContext context) { final emoji = token.getEmoji(); return WordZoomActivityButton( - icon: emoji == null + icon: emoji.isEmpty ? const Icon(Icons.add_reaction_outlined) - : Text( - emoji, + : EmojiStack( + emoji: emoji, style: const TextStyle(fontSize: 24), ), isSelected: isSelected, diff --git a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart index 144d3ed29..6dfc369bd 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart @@ -1,24 +1,22 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; /// The multiple choice activity view class MultipleChoiceActivity extends StatefulWidget { @@ -122,13 +120,15 @@ class MultipleChoiceActivityState extends State { // If the selected choice is correct, send the record if (widget.currentActivity.content.isCorrect(value, index)) { // If the activity is an emoji activity, set the emoji value - if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) { - if (widget.currentActivity.targetTokens?.length != 1) { - debugger(when: kDebugMode); - } else { - widget.currentActivity.targetTokens!.first.setEmoji(value); - } - } + + // TODO: this widget is deprecated for use with emoji activities + // if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) { + // if (widget.currentActivity.targetTokens?.length != 1) { + // debugger(when: kDebugMode); + // } else { + // widget.currentActivity.targetTokens!.first.setEmoji(value); + // } + // } // The next entry in the analytics stream should be from the above putAnalytics.setState. // So we can wait for the stream to update before calling onActivityFinish. @@ -219,7 +219,6 @@ class MultipleChoiceActivityState extends State { messageEvent: widget.practiceCardController.widget.pangeaMessageEvent, overlayController: widget.overlayController, - tts: tts, setIsPlayingAudio: widget.overlayController.setIsPlayingAudio, onError: widget.onError, ), diff --git a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart index 9cf3a476d..498817117 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart @@ -1,31 +1,30 @@ import 'dart:async'; import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; -import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/practice_activities/practice_repo.dart'; +import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; -import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart'; -import 'package:fluffychat/pangea/toolbar/repo/practice_repo.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; /// The wrapper for practice activity content. /// Handles the activities associated with a message, @@ -261,7 +260,8 @@ class PracticeActivityCardState extends State { // wait for savor the joy before popping from the activity queue // to keep the completed activity on screen for a moment - widget.overlayController.onActivityFinish(currentActivity!.activityType); + widget.overlayController + .onActivityFinish(currentActivity!.activityType, null); widget.overlayController.widget.chatController.choreographer.tts.stop(); } catch (e, s) { _onError(); @@ -298,18 +298,19 @@ class PracticeActivityCardState extends State { case ActivityTypeEnum.emoji: case ActivityTypeEnum.morphId: case ActivityTypeEnum.messageMeaning: - final selectedChoice = - currentActivity?.activityType == ActivityTypeEnum.emoji && - (currentActivity?.targetTokens?.isNotEmpty ?? false) - ? currentActivity?.targetTokens?.first.getEmoji() - : null; + // final selectedChoice = + // currentActivity?.activityType == ActivityTypeEnum.emoji && + // (currentActivity?.targetTokens?.isNotEmpty ?? false) + // ? currentActivity?.targetTokens?.first.getEmoji() + // : null; return MultipleChoiceActivity( practiceCardController: this, currentActivity: currentActivity!, event: widget.pangeaMessageEvent.event, onError: _onError, overlayController: widget.overlayController, - initialSelectedChoice: selectedChoice, + // initialSelectedChoice: selectedChoice, + initialSelectedChoice: null, clearResponsesOnUpdate: currentActivity?.activityType == ActivityTypeEnum.emoji, ); @@ -354,6 +355,15 @@ class PracticeActivityCardState extends State { : null, ), ], + Positioned( + left: 0, + top: 0, + child: IconButton( + onPressed: () => widget.overlayController + .updateToolbarMode(MessageMode.wordEmoji), + icon: const Icon(Icons.lightbulb), + ), + ), // Flag button in the top right corner // Positioned( // top: 0, diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart index 26aaa7718..79a20ac9d 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart @@ -1,19 +1,25 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class WordAudioButton extends StatefulWidget { final String text; final double size; + final bool isSelected; + final double baseOpacity; + + /// If defined, this callback will be called instead of the default one + final void Function()? callbackOverride; const WordAudioButton({ super.key, required this.text, this.size = 24, + this.isSelected = false, + this.baseOpacity = 1, + this.callbackOverride, }); @override @@ -24,6 +30,15 @@ class WordAudioButtonState extends State { final TtsController tts = TtsController(); bool _isPlaying = false; + @override + void didUpdateWidget(covariant WordAudioButton oldWidget) { + if (oldWidget.isSelected != widget.isSelected || + oldWidget.callbackOverride != widget.callbackOverride) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + @override void dispose() { tts.dispose(); @@ -33,47 +48,56 @@ class WordAudioButtonState extends State { @override Widget build(BuildContext context) { return CompositedTransformTarget( - link: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').link, - child: IconButton( - key: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').key, - icon: const Icon(Icons.play_arrow_outlined), - isSelected: _isPlaying, - selectedIcon: const Icon(Icons.pause_outlined), - color: _isPlaying ? Colors.white : null, - tooltip: - _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, - iconSize: widget.size, - onPressed: () async { - if (_isPlaying) { - await tts.stop(); - if (mounted) { - setState(() => _isPlaying = false); - } - } else { - if (mounted) { - setState(() => _isPlaying = true); - } - try { - await tts.tryToSpeak( - widget.text, - context, - targetID: 'word-audio-button', - ); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "text": widget.text, - }, - ); - } finally { - if (mounted) { - setState(() => _isPlaying = false); - } - } - } - }, // Disable button if language isn't supported + link: MatrixState.pAnyState + .layerLinkAndKey('word-audio-butto${widget.text}') + .link, + child: Opacity( + opacity: !widget.isSelected ? widget.baseOpacity : 1, + child: IconButton( + key: MatrixState.pAnyState + .layerLinkAndKey('word-audio-butto${widget.text}') + .key, + icon: const Icon(Icons.volume_up), + isSelected: _isPlaying, + selectedIcon: const Icon(Icons.pause_outlined), + color: + widget.isSelected ? Theme.of(context).colorScheme.primary : null, + tooltip: + _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, + iconSize: widget.size, + onPressed: widget.callbackOverride ?? + () async { + if (_isPlaying) { + await tts.stop(); + if (mounted) { + setState(() => _isPlaying = false); + } + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + try { + await tts.tryToSpeak( + widget.text, + context, + targetID: 'word-audio-button', + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "text": widget.text, + }, + ); + } finally { + if (mounted) { + setState(() => _isPlaying = false); + } + } + } + }, // Disable button if language isn't supported + ), ), ); } diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart index 6a87fdc3e..7b9c50d1d 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart @@ -113,7 +113,7 @@ class WordAudioButtonState extends State { widget.text, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: _isPlaying - ? Theme.of(context).colorScheme.secondary + ? Theme.of(context).colorScheme.primary : null, fontSize: textSize, ), @@ -136,8 +136,11 @@ class WordAudioButtonState extends State { ) else Icon( - _isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined, + _isPlaying ? Icons.volume_up : Icons.pause_outlined, size: textSize, + color: _isPlaying + ? Theme.of(context).colorScheme.primary + : null, ), ], ), diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart index d16bf2669..e2907032a 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart @@ -24,11 +24,12 @@ class WordZoomActivityButton extends StatelessWidget { icon: icon, iconSize: 24, color: isSelected ? Theme.of(context).colorScheme.primary : null, - style: IconButton.styleFrom( - backgroundColor: isSelected - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25) - : Colors.transparent, - ), + visualDensity: VisualDensity.compact, + // style: IconButton.styleFrom( + // backgroundColor: isSelected + // ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25) + // : Colors.transparent, + // ), ); if (opacity != null) { diff --git a/lib/pangea/toolbar/widgets/message_toolbar.dart b/lib/pangea/toolbar/widgets/reading_assistance_content.dart similarity index 54% rename from lib/pangea/toolbar/widgets/message_toolbar.dart rename to lib/pangea/toolbar/widgets/reading_assistance_content.dart index 046525de1..99c6bb8ed 100644 --- a/lib/pangea/toolbar/widgets/message_toolbar.dart +++ b/lib/pangea/toolbar/widgets/reading_assistance_content.dart @@ -1,40 +1,42 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix_api_lite/model/message_types.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_meaning_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_mode_locked_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_speech_to_text_card.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/message_translation_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_unsubscribed_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.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 ReadingAssistanceContentCard extends StatelessWidget { +class ReadingAssistanceContent extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overlayController; + final Duration animationDuration; - const ReadingAssistanceContentCard({ + const ReadingAssistanceContent({ super.key, required this.pangeaMessageEvent, required this.overlayController, + this.animationDuration = FluffyThemes.animationDuration, }); + @override + ReadingAssistanceContentState createState() => + ReadingAssistanceContentState(); +} + +class ReadingAssistanceContentState extends State { TtsController get ttsController => - overlayController.widget.chatController.choreographer.tts; + widget.overlayController.widget.chatController.choreographer.tts; Widget? toolbarContent(BuildContext context) { final bool? subscribed = @@ -42,69 +44,76 @@ class ReadingAssistanceContentCard extends StatelessWidget { if (subscribed != null && !subscribed) { return MessageUnsubscribedCard( - controller: overlayController, + controller: widget.overlayController, ); } - if ((overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ?? - false) || - (overlayController.messageAnalyticsEntry?.hasMessageMeaningActivity ?? - false)) { + if (widget.overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ?? + false) { return PracticeActivityCard( - pangeaMessageEvent: pangeaMessageEvent, - overlayController: overlayController, - targetTokensAndActivityType: - overlayController.messageAnalyticsEntry!.nextActivity!, + pangeaMessageEvent: widget.pangeaMessageEvent, + overlayController: widget.overlayController, + targetTokensAndActivityType: widget + .overlayController.messageAnalyticsEntry! + .nextActivity(ActivityTypeEnum.hiddenWordListening)!, location: AnalyticsUpdateOrigin.practiceActivity, ); } - if (!overlayController.initialized) { + if (widget.overlayController.messageAnalyticsEntry + ?.hasMessageMeaningActivity ?? + false) { + return PracticeActivityCard( + pangeaMessageEvent: widget.pangeaMessageEvent, + overlayController: widget.overlayController, + targetTokensAndActivityType: widget + .overlayController.messageAnalyticsEntry! + .nextActivity(ActivityTypeEnum.messageMeaning)!, + location: AnalyticsUpdateOrigin.practiceActivity, + ); + } + + if (!widget.overlayController.initialized) { return const ToolbarContentLoadingIndicator(); } - final unlocked = overlayController.toolbarMode.isUnlocked( - overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted, - overlayController.isPracticeComplete, - ); + // final unlocked = widget.overlayController.toolbarMode + // .isUnlocked(widget.overlayController); - if (!unlocked) { - return MessageModeLockedCard(controller: overlayController); - } + // if (!unlocked) { + // return MessageModeLockedCard(controller: widget.overlayController); + // } - switch (overlayController.toolbarMode) { + switch (widget.overlayController.toolbarMode) { case MessageMode.messageTranslation: - return MessageTranslationCard( - messageEvent: pangeaMessageEvent, - ); - case MessageMode.messageTextToSpeech: - return MessageAudioCard( - messageEvent: pangeaMessageEvent, - overlayController: overlayController, - selection: overlayController.selectedSpan, - tts: ttsController, - setIsPlayingAudio: overlayController.setIsPlayingAudio, - ); + // return MessageTranslationCard( + // messageEvent: widget.pangeaMessageEvent, + // ); case MessageMode.messageSpeechToText: - return MessageSpeechToTextCard( - messageEvent: pangeaMessageEvent, - ); + // return MessageSpeechToTextCard( + // messageEvent: widget.pangeaMessageEvent, + // ); case MessageMode.noneSelected: - return Padding( - padding: const EdgeInsets.all(8), - child: Text( - L10n.of(context).clickWordsInstructions, - textAlign: TextAlign.center, - ), - ); + // return Padding( + // padding: const EdgeInsets.all(8), + // child: Text( + // L10n.of(context).clickWordsInstructions, + // textAlign: TextAlign.center, + // ), + // ); case MessageMode.messageMeaning: - return MessageMeaningCard(controller: overlayController); + // return MessageMeaningCard(controller: widget.overlayController); + case MessageMode.listening: + // return MessageAudioCard( + // messageEvent: widget.overlayController.pangeaMessageEvent!, + // overlayController: widget.overlayController, + // setIsPlayingAudio: widget.overlayController.setIsPlayingAudio); case MessageMode.practiceActivity: case MessageMode.wordZoom: case MessageMode.wordEmoji: case MessageMode.wordMorph: case MessageMode.wordMeaning: - if (overlayController.selectedToken == null) { + if (widget.overlayController.selectedToken == null) { return Padding( padding: const EdgeInsets.all(16), child: Text( @@ -114,10 +123,10 @@ class ReadingAssistanceContentCard extends StatelessWidget { ); } return WordZoomWidget( - token: overlayController.selectedToken!, - messageEvent: overlayController.pangeaMessageEvent!, + token: widget.overlayController.selectedToken!, + messageEvent: widget.overlayController.pangeaMessageEvent!, tts: ttsController, - overlayController: overlayController, + overlayController: widget.overlayController, ); } } @@ -125,7 +134,7 @@ class ReadingAssistanceContentCard extends StatelessWidget { @override Widget build(BuildContext context) { if (![MessageTypes.Text, MessageTypes.Audio].contains( - pangeaMessageEvent.event.messageType, + widget.pangeaMessageEvent.event.messageType, )) { return const SizedBox(); } @@ -149,7 +158,7 @@ class ReadingAssistanceContentCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedSize( - duration: FluffyThemes.animationDuration, + duration: widget.animationDuration, child: toolbarContent(context), ), ], diff --git a/lib/pangea/toolbar/widgets/toolbar_button.dart b/lib/pangea/toolbar/widgets/toolbar_button.dart index 0e431eb4d..5dc414d59 100644 --- a/lib/pangea/toolbar/widgets/toolbar_button.dart +++ b/lib/pangea/toolbar/widgets/toolbar_button.dart @@ -1,9 +1,8 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:flutter/material.dart'; class ToolbarButton extends StatelessWidget { final MessageMode mode; @@ -21,15 +20,12 @@ class ToolbarButton extends StatelessWidget { Color color(BuildContext context) => mode.iconButtonColor( context, - overlayController.toolbarMode, - overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted, - overlayController.isPracticeComplete, + overlayController, ); - bool get enabled => mode.isUnlocked( - overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted, - overlayController.isPracticeComplete, - ); + bool get enabled => mode == MessageMode.messageTranslation + ? overlayController.isTranslationUnlocked + : true; @override Widget build(BuildContext context) { @@ -37,7 +33,7 @@ class ToolbarButton extends StatelessWidget { message: mode.tooltip(context), child: PressableButton( borderRadius: BorderRadius.circular(20), - depressed: mode == overlayController.toolbarMode || !enabled, + depressed: mode == overlayController.toolbarMode, color: color(context), onPressed: () => onPressed(mode), playSound: true, diff --git a/lib/pangea/toolbar/widgets/toolbar_button_and_progress_row.dart b/lib/pangea/toolbar/widgets/toolbar_button_and_progress_column.dart similarity index 67% rename from lib/pangea/toolbar/widgets/toolbar_button_and_progress_row.dart rename to lib/pangea/toolbar/widgets/toolbar_button_and_progress_column.dart index 49ff4f00b..193b5ec9c 100644 --- a/lib/pangea/toolbar/widgets/toolbar_button_and_progress_row.dart +++ b/lib/pangea/toolbar/widgets/toolbar_button_and_progress_column.dart @@ -8,19 +8,16 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart'; class ToolbarButtonAndProgressColumn extends StatelessWidget { final Event event; final MessageOverlayController overlayController; - final bool shouldShowToolbarButtons; final double height; final double width; const ToolbarButtonAndProgressColumn({ required this.event, required this.overlayController, - required this.shouldShowToolbarButtons, required this.height, required this.width, super.key, @@ -37,7 +34,6 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget { @override Widget build(BuildContext context) { if (event.messageType == MessageTypes.Audio || - !shouldShowToolbarButtons || !(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ?? false)) { return SizedBox(height: height, width: width); @@ -77,8 +73,9 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget { margin: barMargin, ), Positioned( - bottom: - height * MessageMode.messageMeaning.pointOnBar - buttonSize, + bottom: height * MessageMode.noneSelected.pointOnBar - + buttonSize / 2 - + barMargin.vertical / 2, child: Container( decoration: BoxDecoration( color: overlayController.isPracticeComplete @@ -98,36 +95,6 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget { ), ), ), - Positioned( - bottom: height * MessageMode.messageTranslation.pointOnBar - - buttonSize / 2, - child: ToolbarButton( - mode: MessageMode.messageTranslation, - overlayController: overlayController, - onPressed: overlayController.updateToolbarMode, - buttonSize: buttonSize, - ), - ), - Positioned( - bottom: height * MessageMode.messageTextToSpeech.pointOnBar - - buttonSize / 2, - child: ToolbarButton( - mode: MessageMode.messageTextToSpeech, - overlayController: overlayController, - onPressed: overlayController.updateToolbarMode, - buttonSize: buttonSize, - ), - ), - Positioned( - bottom: height * MessageMode.practiceActivity.pointOnBar, - child: ToolbarButton( - mode: MessageMode.practiceActivity, - overlayController: overlayController, - onPressed: (mode) => - overlayController.onNextActivityRequest(), - buttonSize: buttonSize, - ), - ), ], ), ], diff --git a/lib/pangea/toolbar/widgets/toolbar_button_column.dart b/lib/pangea/toolbar/widgets/toolbar_button_column.dart new file mode 100644 index 000000000..ed265a534 --- /dev/null +++ b/lib/pangea/toolbar/widgets/toolbar_button_column.dart @@ -0,0 +1,85 @@ +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class ToolbarButtonRow extends StatelessWidget { + final Event event; + final MessageOverlayController overlayController; + final bool shouldShowToolbarButtons; + + const ToolbarButtonRow({ + required this.event, + required this.overlayController, + required this.shouldShowToolbarButtons, + super.key, + }); + + static const double iconWidth = 36.0; + static const double buttonSize = 40.0; + static const barMargin = + EdgeInsets.symmetric(horizontal: iconWidth / 2, vertical: buttonSize / 2); + + @override + Widget build(BuildContext context) { + if (event.messageType == MessageTypes.Audio || + !shouldShowToolbarButtons || + !(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ?? + false)) { + return const SizedBox( + height: 50.0, + ); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + ToolbarButton( + mode: MessageMode.messageTranslation, + overlayController: overlayController, + onPressed: overlayController.updateToolbarMode, + buttonSize: buttonSize, + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + spacing: 4.0, + children: [ + ToolbarButton( + mode: MessageMode.wordMorph, + overlayController: overlayController, + onPressed: overlayController.updateToolbarMode, + buttonSize: buttonSize, + ), + ToolbarButton( + mode: MessageMode.wordMeaning, + overlayController: overlayController, + onPressed: overlayController.updateToolbarMode, + buttonSize: buttonSize, + ), + ToolbarButton( + mode: MessageMode.listening, + overlayController: overlayController, + onPressed: overlayController.updateToolbarMode, + buttonSize: buttonSize, + ), + ToolbarButton( + mode: MessageMode.wordEmoji, + overlayController: overlayController, + onPressed: overlayController.updateToolbarMode, + buttonSize: buttonSize, + ), + ], + ), + ], + ); + } +} diff --git a/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart index 69a3151ff..f4aa8f29b 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart @@ -1,11 +1,5 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:material_symbols_icons/symbols.dart'; - import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; @@ -13,11 +7,15 @@ import 'package:fluffychat/pangea/learning_settings/constants/language_constants import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart'; import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.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:material_symbols_icons/symbols.dart'; class LemmaMeaningWidget extends StatefulWidget { final ConstructUses constructUse; @@ -27,7 +25,7 @@ class LemmaMeaningWidget extends StatefulWidget { /// These are not present if this widget is used outside the chat /// (e.g. in the vocab details view) - /// we're going to punt on letting the user assign the meaning in the vocab details view + /// TODO: let the user assign the meaning in the vocab details view final MessageOverlayController? controller; final PangeaToken? token; @@ -48,6 +46,9 @@ class LemmaMeaningWidget extends StatefulWidget { class LemmaMeaningWidgetState extends State { bool _editMode = false; late TextEditingController _controller; + LemmaInfoResponse? _lemmaInfo; + bool _isLoading = true; + String? _error; String get _lemma => widget.constructUse.lemma; @@ -55,6 +56,16 @@ class LemmaMeaningWidgetState extends State { void initState() { super.initState(); _controller = TextEditingController(); + _fetchLemmaMeaning(); + } + + @override + void didUpdateWidget(covariant LemmaMeaningWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.constructUse != widget.constructUse || + oldWidget.langCode != widget.langCode) { + _fetchLemmaMeaning(); + } } @override @@ -74,29 +85,42 @@ class LemmaMeaningWidgetState extends State { LanguageKeys.defaultLanguage, ); - Future _lemmaMeaning() => LemmaInfoRepo.get(_request); + Future _fetchLemmaMeaning() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + _lemmaInfo = await LemmaInfoRepo.get(_request); + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } void _toggleEditMode(bool value) => setState(() => _editMode = value); Future editLemmaMeaning(String userEdit) async { - final originalMeaning = await _lemmaMeaning(); + final originalMeaning = _lemmaInfo; - LemmaInfoRepo.set( - _request, - LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit), - ); + if (originalMeaning != null) { + LemmaInfoRepo.set( + _request, + LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit), + ); - _toggleEditMode(false); + _toggleEditMode(false); + _fetchLemmaMeaning(); + } } @override Widget build(BuildContext context) { if (widget.token != null && - widget.token!.shouldDoActivity( - a: ActivityTypeEnum.wordMeaning, - feature: null, - tag: null, - )) { + widget.controller?.messageAnalyticsEntry != null && + widget.controller!.messageAnalyticsEntry! + .hasActivity(ActivityTypeEnum.wordMeaning, widget.token!)) { return WordZoomActivityButton( icon: const Icon(Symbols.dictionary), isSelected: widget.controller?.toolbarMode == MessageMode.wordMeaning, @@ -106,107 +130,110 @@ class LemmaMeaningWidgetState extends State { widget.controller!.updateToolbarMode(MessageMode.wordMeaning); } : () => {}, + opacity: + widget.controller?.toolbarMode == MessageMode.wordMeaning ? 1 : 0.4, ); } - return FutureBuilder( - future: _lemmaMeaning(), - builder: (context, snapshot) { - if (_editMode) { - _controller.text = snapshot.data?.meaning ?? ""; - return Column( - mainAxisSize: MainAxisSize.min, + if (_isLoading) { + return const TextLoadingShimmer(); + } + + if (_error != null) { + debugger(when: kDebugMode); + return Text( + L10n.of(context).oopsSomethingWentWrong, + textAlign: TextAlign.center, + ); + } + + if (_editMode) { + _controller.text = _lemmaInfo?.meaning ?? ""; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(_lemma, widget.constructUse.category)}", + textAlign: TextAlign.center, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: TextField( + minLines: 1, + maxLines: 3, + controller: _controller, + decoration: InputDecoration( + hintText: _lemmaInfo?.meaning, + ), + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - "${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(_lemma, widget.constructUse.category)}", - textAlign: TextAlign.center, - style: const TextStyle(fontStyle: FontStyle.italic), - ), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: TextField( - minLines: 1, - maxLines: 3, - controller: _controller, - decoration: InputDecoration( - hintText: snapshot.data!.meaning, + ElevatedButton( + onPressed: () => _toggleEditMode(false), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), ), + padding: const EdgeInsets.symmetric(horizontal: 10), ), + child: Text(L10n.of(context).cancel), ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () => _toggleEditMode(false), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - child: Text(L10n.of(context).cancel), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () => _controller.text != _lemmaInfo?.meaning && + _controller.text.isNotEmpty + ? editLemmaMeaning(_controller.text) + : null, + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), ), - const SizedBox(width: 10), - ElevatedButton( - onPressed: () => - _controller.text != snapshot.data!.meaning && - _controller.text.isNotEmpty - ? editLemmaMeaning(_controller.text) - : null, - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - child: Text(L10n.of(context).saveChanges), - ), - ], + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: Text(L10n.of(context).saveChanges), ), ], - ); - } + ), + ], + ); + } - if (snapshot.connectionState != ConnectionState.done) { - return const TextLoadingShimmer(); - } - - if (snapshot.hasError || snapshot.data == null) { - debugger(when: kDebugMode); - return Text( - L10n.of(context).oopsSomethingWentWrong, - textAlign: TextAlign.center, - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Tooltip( - triggerMode: TooltipTriggerMode.tap, - message: L10n.of(context).doubleClickToEdit, - child: GestureDetector( - onLongPress: () => _toggleEditMode(true), - onDoubleTap: () => _toggleEditMode(true), - child: RichText( - text: TextSpan( - style: widget.style, - children: [ - if (widget.leading != null) widget.leading!, - if (widget.leading != null) const TextSpan(text: ' '), - TextSpan(text: snapshot.data!.meaning), - ], - ), + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: L10n.of(context).doubleClickToEdit, + child: GestureDetector( + onLongPress: () => _toggleEditMode(true), + onDoubleTap: () => _toggleEditMode(true), + child: RichText( + text: TextSpan( + style: widget.style?.copyWith( + color: widget.controller?.toolbarMode == + MessageMode.wordMeaning + ? Theme.of(context).colorScheme.primary + : null, ), + children: [ + if (widget.leading != null) widget.leading!, + if (widget.leading != null) const TextSpan(text: ' '), + TextSpan( + text: _lemmaInfo?.meaning, + ), + ], ), ), ), - ], - ); - }, + ), + ), + ], ); } } diff --git a/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart index fb2df9d50..d75865b2a 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/lemma_widget.dart @@ -1,7 +1,3 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; @@ -9,8 +5,12 @@ import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class LemmaWidget extends StatefulWidget { final PangeaToken token; @@ -18,6 +18,7 @@ class LemmaWidget extends StatefulWidget { final VoidCallback onEdit; final VoidCallback onEditDone; final TtsController tts; + final MessageOverlayController? overlayController; const LemmaWidget({ super.key, @@ -26,6 +27,7 @@ class LemmaWidget extends StatefulWidget { required this.onEdit, required this.onEditDone, required this.tts, + required this.overlayController, }); @override @@ -162,16 +164,39 @@ class LemmaWidgetState extends State { ); } - return Tooltip( - triggerMode: TooltipTriggerMode.tap, - message: L10n.of(context).doubleClickToEdit, - child: GestureDetector( - onLongPress: () => _toggleEditMode(true), - onDoubleTap: () => _toggleEditMode(true), - child: WordTextWithAudioButton( - text: widget.token.lemma.text, + return Row( + children: [ + Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: L10n.of(context).doubleClickToEdit, + child: GestureDetector( + onLongPress: () => _toggleEditMode(true), + onDoubleTap: () => _toggleEditMode(true), + child: Text( + widget.token.lemma.text, + style: Theme.of(context).textTheme.headlineSmall, + overflow: TextOverflow.ellipsis, + ), + ), ), - ), + if (widget.token.lemma.text.toLowerCase() == + widget.token.text.content.toLowerCase()) + WordAudioButton( + text: widget.token.text.content, + isSelected: + MessageMode.listening == widget.overlayController?.toolbarMode, + baseOpacity: 0.4, + callbackOverride: + widget.overlayController?.messageAnalyticsEntry?.hasActivity( + MessageMode.listening.associatedActivityType!, + widget.token, + ) == + true + ? () => widget.overlayController + ?.updateToolbarMode(MessageMode.listening) + : null, + ), + ], ); } } diff --git a/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart index c013774bb..0d4cede32 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart @@ -1,11 +1,8 @@ // stateful widget that displays morphological label and a shimmer effect while the text is loading // takes a token and morphological feature as input -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/common/constants/model_keys.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; @@ -19,10 +16,13 @@ import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_feature_display.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_repo.dart'; import 'package:fluffychat/pangea/morphs/morph_tag_display.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class MorphFocusWidget extends StatefulWidget { final PangeaToken token; @@ -54,17 +54,18 @@ class MorphFocusWidgetState extends State { final ScrollController _scrollController = ScrollController(); void resetMorphTag() => setState( - () => selectedMorphTag = widget.token.morph[widget.morphFeature]!, + () => selectedMorphTag = + widget.token.getMorphTag(widget.morphFeature) ?? "X", ); @override void didUpdateWidget(MorphFocusWidget oldWidget) { - super.didUpdateWidget(oldWidget); if (widget.token != oldWidget.token || widget.morphFeature != oldWidget.morphFeature) { resetMorphTag(); setState(() => editMode = false); } + super.didUpdateWidget(oldWidget); } @override @@ -171,40 +172,51 @@ class MorphFocusWidgetState extends State { children: [ MorphFeatureDisplay( morphFeature: widget.morphFeature, - morphTag: selectedMorphTag, ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Tooltip( - triggerMode: TooltipTriggerMode.tap, - message: L10n.of(context).doubleClickToEdit, - child: GestureDetector( - onLongPress: enterEditMode, - onDoubleTap: enterEditMode, - child: MorphTagDisplay( - morphFeature: widget.morphFeature, - textColor: Theme.of(context).brightness == - Brightness.light - ? id.constructUses.lemmaCategory.darkColor(context) - : id.constructUses.lemmaCategory.color(context), + if (widget.token.getMorphTag(widget.morphFeature) != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + triggerMode: TooltipTriggerMode.tap, + message: L10n.of(context).doubleClickToEdit, + child: GestureDetector( + onLongPress: enterEditMode, + onDoubleTap: enterEditMode, + child: MorphTagDisplay( + morphFeature: MorphFeaturesEnumExtension.fromString( + widget.morphFeature, + ), + morphTag: + widget.token.getMorphTag(widget.morphFeature) ?? + L10n.of(context).nan, + textColor: Theme.of(context).brightness == + Brightness.light + ? id.constructUses.lemmaCategory.darkColor(context) + : id.constructUses.lemmaCategory.color(context), + ), ), ), - ), - const SizedBox(width: 6), - ConstructXpWidget( - id: id, - onTap: () => showDialog( - context: context, - builder: (context) => AnalyticsPopupWrapper( - constructZoom: id, - view: ConstructTypeEnum.morph, + const SizedBox(width: 6), + ConstructXpWidget( + id: id, + onTap: () => showDialog( + context: context, + builder: (context) => AnalyticsPopupWrapper( + constructZoom: id, + view: ConstructTypeEnum.morph, + ), ), ), - ), - ], - ), + ], + ), + MorphMeaningWidget( + feature: widget.morphFeature, + tag: widget.token.getMorphTag(widget.morphFeature)!, + ), + ] else + Text(L10n.of(context).nan), ], ), ); @@ -220,91 +232,72 @@ class MorphFocusWidgetState extends State { textAlign: TextAlign.center, style: const TextStyle(fontStyle: FontStyle.italic), ), - Container( - constraints: const BoxConstraints(maxHeight: 50), - child: Scrollbar( - controller: _scrollController, - thumbVisibility: true, - child: SingleChildScrollView( - controller: _scrollController, - scrollDirection: Axis.horizontal, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: FutureBuilder( - future: MorphsRepo.get(), - builder: (context, snapshot) { - final allMorphTagsForEdit = snapshot.data - ?.getDisplayTags(widget.morphFeature) ?? - defaultMorphMapping - .getDisplayTags(widget.morphFeature); + FutureBuilder( + future: MorphsRepo.get(), + builder: (context, snapshot) { + final allMorphTagsForEdit = + snapshot.data?.getDisplayTags(widget.morphFeature) ?? + defaultMorphMapping.getDisplayTags(widget.morphFeature); - return snapshot.connectionState == ConnectionState.done - ? Row( - children: allMorphTagsForEdit.map((tag) { - return Container( - margin: const EdgeInsets.all(2), - padding: EdgeInsets.zero, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(10), - ), - border: Border.all( - color: selectedMorphTag == tag - ? Theme.of(context) - .colorScheme - .primary - : Colors.transparent, - style: BorderStyle.solid, - width: 2.0, - ), - ), - child: TextButton( - style: ButtonStyle( - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric( - horizontal: 7, - ), - ), - backgroundColor: - WidgetStateProperty.all( - selectedMorphTag == tag - ? Theme.of(context) - .colorScheme - .primary - .withAlpha(50) - : Colors.transparent, - ), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(10), - ), - ), - ), - onPressed: () { - setState(() => selectedMorphTag = tag); - }, - child: Text( - getGrammarCopy( - category: widget.morphFeature, - lemma: tag, - context: context, - ) ?? - tag, - textAlign: TextAlign.center, - ), - ), - ); - }).toList(), - ) - : const Center( - child: CircularProgressIndicator(), - ); - }, - ), - ), - ), - ), + if (snapshot.connectionState == ConnectionState.waiting) { + return const CircularProgressIndicator(); + } + + return Wrap( + children: allMorphTagsForEdit.map((tag) { + return Container( + margin: const EdgeInsets.all(2), + padding: EdgeInsets.zero, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), + border: Border.all( + color: selectedMorphTag == tag + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + style: BorderStyle.solid, + width: 2.0, + ), + ), + child: TextButton( + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric( + horizontal: 7, + ), + ), + backgroundColor: WidgetStateProperty.all( + selectedMorphTag == tag + ? Theme.of(context) + .colorScheme + .primary + .withAlpha(50) + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + onPressed: () { + setState(() => selectedMorphTag = tag); + }, + child: Text( + getGrammarCopy( + category: widget.morphFeature, + lemma: tag, + context: context, + ) ?? + tag, + textAlign: TextAlign.center, + ), + ), + ); + }).toList(), + ); + }, ), Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_list_item.dart b/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_list_item.dart index f2e1e6cd9..43bbbbc27 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_list_item.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/morphs/morphological_list_item.dart @@ -1,59 +1,74 @@ +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; +import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart'; import 'package:flutter/material.dart'; - import 'package:material_symbols_icons/symbols.dart'; -import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; -import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart'; -import 'package:fluffychat/pangea/morphs/morph_icon.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart'; - class MorphologicalListItem extends StatelessWidget { - final Function(String) onPressed; - final String morphFeature; - final String morphTag; - - final bool isUnlocked; - final bool isSelected; + final MorphFeaturesEnum morphFeature; + final PangeaToken token; + final MessageOverlayController overlayController; const MorphologicalListItem({ - required this.onPressed, required this.morphFeature, - required this.morphTag, - this.isUnlocked = true, - this.isSelected = false, + required this.token, + required this.overlayController, super.key, }); + bool get shouldDoActivity => + overlayController.messageAnalyticsEntry?.hasActivity( + ActivityTypeEnum.morphId, + token, + morphFeature, + ) == + true; + + bool get isSelected => overlayController.toolbarMode == MessageMode.wordMorph; + + String get morphTag => token.getMorphTag(morphFeature.name) ?? "X"; + @override Widget build(BuildContext context) { return SizedBox( width: 40, height: 40, child: WordZoomActivityButton( - icon: isUnlocked - // if unlocked, we show the specific icon for the morphological feature/tag - ? MorphIcon(morphFeature: morphFeature, morphTag: morphTag) - // we show general feature icon if the morph is locked - // : MorphIcon(morphFeature: morphFeature, morphTag: null), - // or maybe we should the general grammar icon to show the - // connection between these and the grammar icon in the progress header - : const Icon(Symbols.toys_and_games), + icon: shouldDoActivity + ? const Icon(Symbols.toys_and_games) + : MorphIcon( + morphFeature: morphFeature, + morphTag: token.getMorphTag(morphFeature.name), + size: const Size(24, 24), + ), isSelected: isSelected, - onPressed: () => onPressed(morphFeature), - tooltip: isUnlocked - ? getGrammarCopy( - category: morphFeature, + // onPressed: shouldDoActivity + // ? () => overlayController.updateToolbarMode(MessageMode.wordMorph) + // : () => (feature) => showDialog( + // context: context, + // builder: (context) => AnalyticsPopupWrapper( + // constructZoom: token.morphIdByFeature(feature), + // view: ConstructTypeEnum.vocab, + // ), + // ), + onPressed: () => + overlayController.onMorphActivitySelect(token, morphFeature), + tooltip: shouldDoActivity + ? morphFeature.getDisplayCopy(context) + : getGrammarCopy( + category: morphFeature.name, lemma: morphTag, context: context, - ) - : getMorphologicalCategoryCopy( - morphFeature, - context, ), opacity: isSelected ? 1 - : !isUnlocked - ? 0.2 + : shouldDoActivity + ? 0.4 : 1, ), ); diff --git a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_selection_enum.dart b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_selection_enum.dart index 0339c2ebb..b6a86fea8 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_selection_enum.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_selection_enum.dart @@ -1,4 +1,4 @@ -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; +import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart'; enum WordZoomSelection { meaning, diff --git a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart index 0cdbf2553..f2681029b 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart @@ -1,5 +1,3 @@ -import 'package:flutter/material.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; @@ -9,16 +7,17 @@ import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart'; +import 'package:fluffychat/pangea/lemmas/lemma_emoji_row.dart'; +import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; -import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/emoji_practice_button.dart'; -import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_widget.dart'; import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/morphs/morphological_list_item.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; class WordZoomWidget extends StatelessWidget { final PangeaToken token; @@ -36,10 +35,6 @@ class WordZoomWidget extends StatelessWidget { PangeaToken get _selectedToken => overlayController.selectedToken!; - MessageMode get _mode => overlayController.toolbarMode; - - String? get _selectedMorphFeature => overlayController.selectedMorphFeature; - void onEditDone() => overlayController.initializeTokensAndMode(); @override @@ -66,32 +61,30 @@ class WordZoomWidget extends StatelessWidget { children: [ Container( constraints: const BoxConstraints( - minHeight: 50, + minHeight: 40, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - EmojiPracticeButton( + //@ggurdin - might need to play with size to properly center + IconButton( + onPressed: () => overlayController + .onClickOverlayMessageToken(token), + icon: const Icon(Icons.close)), + LemmaWidget( token: _selectedToken, - onPressed: () => overlayController.updateToolbarMode( - MessageMode.wordEmoji, - ), - isSelected: _mode == MessageMode.wordEmoji, - ), - Expanded( - child: LemmaWidget( - token: _selectedToken, - pangeaMessageEvent: messageEvent, - // onEdit: () => _setHideCenterContent(true), - onEdit: () { - debugPrint("what are we doing edits with?"); - }, - onEditDone: () { - debugPrint("what are we doing edits with?"); - onEditDone(); - }, - tts: tts, - ), + pangeaMessageEvent: messageEvent, + // onEdit: () => _setHideCenterContent(true), + onEdit: () { + debugPrint("what are we doing edits with?"); + }, + onEditDone: () { + debugPrint("what are we doing edits with?"); + onEditDone(); + }, + tts: tts, + overlayController: overlayController, ), ConstructXpWidget( id: token.vocabConstructID, @@ -106,51 +99,94 @@ class WordZoomWidget extends StatelessWidget { ], ), ), + const SizedBox( + height: 8.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + constraints: const BoxConstraints( + minHeight: 40, + ), + alignment: Alignment.center, + child: LemmaEmojiRow( + cId: _selectedToken.vocabConstructID, + onTap: () => overlayController.updateToolbarMode( + MessageMode.wordEmoji, + ), + isSelected: overlayController.toolbarMode == + MessageMode.wordEmoji, + removeCallback: () => overlayController.setState(() {}), + ), + ), + ], + ), + const SizedBox( + height: 8.0, + ), Container( constraints: const BoxConstraints( - minHeight: 50, + minHeight: 40, ), alignment: Alignment.center, - child: LemmaMeaningWidget( - constructUse: token.vocabConstructID.constructUses, - langCode: MatrixState.pangeaController.languageController - .userL2?.langCodeShort ?? - LanguageKeys.defaultLanguage, - token: overlayController.selectedToken!, - controller: overlayController, - style: DefaultTextStyle.of(context).style, + child: Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + LemmaMeaningWidget( + constructUse: token.vocabConstructID.constructUses, + langCode: MatrixState.pangeaController + .languageController.userL2?.langCodeShort ?? + LanguageKeys.defaultLanguage, + token: overlayController.selectedToken!, + controller: overlayController, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], ), ), const SizedBox( - height: 16.0, + height: 8.0, ), Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (!_selectedToken.doesLemmaTextMatchTokenText) - WordTextWithAudioButton( - text: _selectedToken.text.content, - textSize: - Theme.of(context).textTheme.titleMedium?.fontSize, + if (!_selectedToken.doesLemmaTextMatchTokenText) ...[ + Text( + _selectedToken.text.content, + style: Theme.of(context).textTheme.bodyLarge, + overflow: TextOverflow.ellipsis, ), - ..._selectedToken.sortedMorphs.map( - (featureTagPair) => MorphologicalListItem( - onPressed: (feature) => - overlayController.updateToolbarMode( - MessageMode.wordMorph, - feature, + WordAudioButton( + text: _selectedToken.text.content, + isSelected: MessageMode.listening == + overlayController.toolbarMode, + baseOpacity: 0.4, + callbackOverride: overlayController + .messageAnalyticsEntry + ?.hasActivity( + MessageMode.listening.associatedActivityType!, + _selectedToken, + ) == + true + ? () => overlayController + .updateToolbarMode(MessageMode.listening) + : null, + ), + ], + ..._selectedToken + .morphsBasicallyEligibleForPracticeByPriority + .map( + (cId) => MorphologicalListItem( + morphFeature: MorphFeaturesEnumExtension.fromString( + cId.category, ), - morphFeature: featureTagPair.key, - morphTag: featureTagPair.value, - isUnlocked: !overlayController.pangeaMessageEvent! - .shouldDoActivity( - token: token, - a: ActivityTypeEnum.morphId, - feature: featureTagPair.key, - tag: featureTagPair.value, - ), - isSelected: _selectedMorphFeature == featureTagPair.key, + token: _selectedToken, + overlayController: overlayController, ), ), ], diff --git a/lib/pangea/word_bank/vocab_bank_repo.dart b/lib/pangea/word_bank/vocab_bank_repo.dart index ebabab373..84b8c852d 100644 --- a/lib/pangea/word_bank/vocab_bank_repo.dart +++ b/lib/pangea/word_bank/vocab_bank_repo.dart @@ -102,7 +102,7 @@ class VocabRepo { return VocabResponse(vocab: deduped); } - Future getSemanticallySimilarWords( + static Future getSemanticallySimilarWords( VocabRequest request, ) async { // Pull from a list of semantically similar words @@ -114,10 +114,8 @@ class VocabRepo { final sharingPos = candidates .where( (element) => - request.token == null || - (element.category.toLowerCase() == - request.token?.pos.toLowerCase() && - element.lemma != request.token?.lemma.text), + (element.category.toLowerCase() == request.pos?.toLowerCase() && + element.lemma.toLowerCase() != request.lemma?.toLowerCase()), ) .toList(); @@ -138,10 +136,8 @@ class VocabRepo { final sharingPos = candidates .where( (element) => - request.token == null || - (element.category.toLowerCase() != - request.token?.pos.toLowerCase() && - element.lemma != request.token?.lemma.text), + element.category.toLowerCase() != request.pos?.toLowerCase() && + element.lemma.toLowerCase() != request.lemma?.toLowerCase(), ) .toList(); @@ -170,6 +166,7 @@ class VocabRepo { ? PLanguageStore.byLangCode(LanguageKeys.defaultLanguage) : MatrixState.pangeaController.languageController.userL2!; + //TODO - move this to the server and fill out all our languages final Map placeholder = { "es": VocabResponse( vocab: [ diff --git a/lib/pangea/word_bank/vocab_request.dart b/lib/pangea/word_bank/vocab_request.dart index 30fc64db2..df2b14450 100644 --- a/lib/pangea/word_bank/vocab_request.dart +++ b/lib/pangea/word_bank/vocab_request.dart @@ -1,4 +1,3 @@ -import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; class VocabRequest { @@ -6,14 +5,16 @@ class VocabRequest { LanguageLevelTypeEnum level; String? prefix; String? suffix; - PangeaToken? token; + String? lemma; + String? pos; int count; VocabRequest({ required this.langCode, required this.level, - this.token, + this.lemma, + this.pos, this.prefix, this.suffix, this.count = 10, @@ -25,15 +26,16 @@ class VocabRequest { prefix = json['prefix'], suffix = json['suffix'], count = json['count'], - token = - json['token'] != null ? PangeaToken.fromJson(json['token']) : null; + lemma = json['lemma'], + pos = json['pos']; Map toJson() => { 'langCode': langCode, 'level': level.index, 'prefix': prefix, 'suffix': suffix, - 'token': token?.toJson(), + 'lemma': lemma, + 'pos': pos, 'count': count, }; @@ -47,7 +49,9 @@ class VocabRequest { level == other.level && prefix == other.prefix && suffix == other.suffix && - count == other.count; + count == other.count && + lemma == other.lemma && + pos == other.pos; } return false; } @@ -58,5 +62,7 @@ class VocabRequest { level.hashCode ^ prefix.hashCode ^ suffix.hashCode ^ - count.hashCode; + count.hashCode ^ + lemma.hashCode ^ + pos.hashCode; } diff --git a/lib/pangea/writing_assistance/writing_assistance_input_row.dart b/lib/pangea/writing_assistance/writing_assistance_input_row.dart index 0c8be567f..462c8b5d3 100644 --- a/lib/pangea/writing_assistance/writing_assistance_input_row.dart +++ b/lib/pangea/writing_assistance/writing_assistance_input_row.dart @@ -1,113 +1,113 @@ -// presents choices from vocab_bank_repo -// displays them as emoji choices -// once selection, these words are inserted into the input bar +// // presents choices from vocab_bank_repo +// // displays them as emoji choices +// // once selection, these words are inserted into the input bar -import 'dart:async'; +// import 'dart:async'; -import 'package:flutter/material.dart'; +// import 'package:fluffychat/config/themes.dart'; +// import 'package:fluffychat/pages/chat/chat.dart'; +// import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +// import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +// import 'package:fluffychat/pangea/emojis/emoji_stack.dart'; +// import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +// import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart'; +// import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart'; +// import 'package:fluffychat/pangea/word_bank/vocab_request.dart'; +// import 'package:fluffychat/pangea/word_bank/vocab_response.dart'; +// import 'package:fluffychat/widgets/matrix.dart'; +// import 'package:flutter/material.dart'; -import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; -import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; -import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; -import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart'; -import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart'; -import 'package:fluffychat/pangea/word_bank/vocab_request.dart'; -import 'package:fluffychat/pangea/word_bank/vocab_response.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +// class WritingAssistanceInputRow extends StatefulWidget { +// final ChatController controller; -class WritingAssistanceInputRow extends StatefulWidget { - final ChatController controller; +// const WritingAssistanceInputRow( +// this.controller, { +// super.key, +// }); - const WritingAssistanceInputRow( - this.controller, { - super.key, - }); +// @override +// WritingAssistanceInputRowState createState() => +// WritingAssistanceInputRowState(); +// } - @override - WritingAssistanceInputRowState createState() => - WritingAssistanceInputRowState(); -} +// class WritingAssistanceInputRowState extends State { +// List suggestions = []; -class WritingAssistanceInputRowState extends State { - List suggestions = []; +// StreamSubscription? _choreoSub; - StreamSubscription? _choreoSub; +// Choreographer get choreographer => widget.controller.choreographer; - Choreographer get choreographer => widget.controller.choreographer; +// @override +// void initState() { +// // Rebuild the widget each time there's an update from choreo +// _choreoSub = choreographer.stateListener.stream.listen((_) { +// setSuggestions(); +// }); +// setSuggestions(); +// super.initState(); +// } - @override - void initState() { - // Rebuild the widget each time there's an update from choreo - _choreoSub = choreographer.stateStream.stream.listen((_) { - setSuggestions(); - }); - setSuggestions(); - super.initState(); - } +// @override +// void dispose() { +// _choreoSub?.cancel(); +// super.dispose(); +// } - @override - void dispose() { - _choreoSub?.cancel(); - super.dispose(); - } +// Future setSuggestions() async { +// final String currentText = choreographer.currentText; - Future setSuggestions() async { - final String currentText = choreographer.currentText; +// final VocabRequest request = VocabRequest( +// langCode: MatrixState +// .pangeaController.languageController.userL2?.langCodeShort ?? +// LanguageKeys.defaultLanguage, +// level: MatrixState +// .pangeaController.userController.profile.userSettings.cefrLevel, +// prefix: currentText, +// ); - final VocabRequest request = VocabRequest( - langCode: MatrixState - .pangeaController.languageController.userL2?.langCodeShort ?? - LanguageKeys.defaultLanguage, - level: MatrixState - .pangeaController.userController.profile.userSettings.cefrLevel, - prefix: currentText, - ); +// final VocabResponse response = await VocabRepo.get(request); - final VocabResponse response = await VocabRepo.get(request); +// setState(() { +// suggestions = response.vocab; +// }); +// } - setState(() { - suggestions = response.vocab; - }); - } - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - duration: FluffyThemes.animationDuration, - curve: FluffyThemes.animationCurve, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: suggestions - .map( - (suggestion) => MessageEmojiChoiceItem( - topContent: Text( - suggestion.userSetEmoji ?? - MatrixState - .pangeaController.getAnalytics.constructListModel - .getConstructUses(suggestion) - ?.xpEmoji ?? - AnalyticsConstants.emojiForSeed, - style: const TextStyle(fontSize: 24), - ), - content: suggestion.lemma, - onTap: () { - choreographer.onPredictorSelect(suggestion.lemma); - // setState(() { - // suggestions = []; - // }); - }, - isSelected: false, - textSize: 16, - greenHighlight: false, - ), - ) - .toList(), - ), - ), - ); - } -} +// @override +// Widget build(BuildContext context) { +// return AnimatedContainer( +// duration: FluffyThemes.animationDuration, +// curve: FluffyThemes.animationCurve, +// child: SingleChildScrollView( +// scrollDirection: Axis.horizontal, +// child: Row( +// children: suggestions +// .map( +// (suggestion) => MessageEmojiChoiceItem( +// topContent: EmojiStack( +// emoji: suggestion.userSetEmoji, +// // suggestion.userSetEmoji ?? +// // MatrixState +// // .pangeaController.getAnalytics.constructListModel +// // .getConstructUses(suggestion) +// // ?.xpEmoji ?? +// // AnalyticsConstants.emojiForSeed, +// style: const TextStyle(fontSize: 24), +// ), +// content: suggestion.lemma, +// onTap: () { +// choreographer.onPredictorSelect(suggestion.lemma); +// // setState(() { +// // suggestions = []; +// // }); +// }, +// isSelected: false, +// textSize: 16, +// greenHighlight: false, +// ), +// ) +// .toList(), +// ), +// ), +// ); +// } +// } diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 7aa92f6ba..822d7246a 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -124,7 +124,7 @@ abstract class ClientManager { PangeaEventTypes.botOptions, PangeaEventTypes.capacity, EventTypes.RoomPowerLevels, - PangeaEventTypes.userChosenEmoji, + PangeaEventTypes.userSetLemmaInfo, EventTypes.RoomJoinRules, // Pangea# },