From 6c792e3f23a5bf2fc344a2c11cff75d84b59cf81 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Wed, 18 Sep 2024 16:04:41 -0400 Subject: [PATCH 1/7] fix bot dialog confirm kicks the bot undesirably --- assets/l10n/intl_en.arb | 1 + lib/pages/new_group/new_group.dart | 5 +-- lib/pangea/constants/bot_mode.dart | 6 ++++ lib/pangea/models/bot_options_model.dart | 8 ++--- .../conversation_bot_mode_dynamic_zone.dart | 14 ++++---- .../conversation_bot_mode_select.dart | 15 ++++---- .../conversation_bot_settings.dart | 12 ++++--- .../conversation_bot_settings_form.dart | 3 +- pubspec.lock | 36 +++++++++---------- 9 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 lib/pangea/constants/bot_mode.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6312008d6..7e7a96d45 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4014,6 +4014,7 @@ "conversationBotModeSelectOption_custom": "Custom", "conversationBotModeSelectOption_conversation": "Conversation", "conversationBotModeSelectOption_textAdventure": "Text Adventure", + "conversationBotModeSelectOption_storyGame": "Story Game", "conversationBotDiscussionZone_title": "Discussion Settings", "conversationBotDiscussionZone_discussionTopicLabel": "Discussion Topic", "conversationBotDiscussionZone_discussionTopicPlaceholder": "Set Discussion Topic", diff --git a/lib/pages/new_group/new_group.dart b/lib/pages/new_group/new_group.dart index aa3c54708..3d8301ec5 100644 --- a/lib/pages/new_group/new_group.dart +++ b/lib/pages/new_group/new_group.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:fluffychat/pages/new_group/new_group_view.dart'; +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/chat_topic_model.dart'; @@ -108,7 +109,7 @@ class NewGroupController extends State { final addBot = addConversationBotKey.currentState?.addBot ?? false; if (addBot) { final botOptions = addConversationBotKey.currentState!.botOptions; - if (botOptions.mode == "custom") { + if (botOptions.mode == BotMode.custom) { if (botOptions.customSystemPrompt == null || botOptions.customSystemPrompt!.isEmpty) { setState(() { @@ -118,7 +119,7 @@ class NewGroupController extends State { }); return; } - } else if (botOptions.mode == "text_adventure") { + } else if (botOptions.mode == BotMode.textAdventure) { if (botOptions.textAdventureGameMasterInstructions == null || botOptions.textAdventureGameMasterInstructions!.isEmpty) { setState(() { diff --git a/lib/pangea/constants/bot_mode.dart b/lib/pangea/constants/bot_mode.dart new file mode 100644 index 000000000..96aa51e72 --- /dev/null +++ b/lib/pangea/constants/bot_mode.dart @@ -0,0 +1,6 @@ +class BotMode { + static const discussion = "discussion"; + static const custom = "custom"; + static const storyGame = "story_game"; + static const textAdventure = "text_adventure"; +} diff --git a/lib/pangea/models/bot_options_model.dart b/lib/pangea/models/bot_options_model.dart index e67021f77..db2725edc 100644 --- a/lib/pangea/models/bot_options_model.dart +++ b/lib/pangea/models/bot_options_model.dart @@ -1,12 +1,12 @@ import 'dart:developer'; +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; -import '../constants/pangea_event_types.dart'; - class BotOptionsModel { int? languageLevel; String topic; @@ -30,7 +30,7 @@ class BotOptionsModel { this.topic = "General Conversation", this.keywords = const [], this.safetyModeration = true, - this.mode = "discussion", + this.mode = BotMode.discussion, //////////////////////////////////////////////////////////////////////////// // Discussion Mode Options @@ -62,7 +62,7 @@ class BotOptionsModel { ? json[ModelKey.languageLevel] : null, safetyModeration: json[ModelKey.safetyModeration] ?? true, - mode: json[ModelKey.mode] ?? "discussion", + mode: json[ModelKey.mode] ?? BotMode.discussion, ////////////////////////////////////////////////////////////////////////// // Discussion Mode Options diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart index e7d8f55a9..90d7ed789 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart @@ -1,6 +1,6 @@ +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_custom_zone.dart'; -import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_text_adventure_zone.dart'; import 'package:flutter/material.dart'; import 'conversation_bot_discussion_zone.dart'; @@ -18,20 +18,18 @@ class ConversationBotModeDynamicZone extends StatelessWidget { @override Widget build(BuildContext context) { final zoneMap = { - 'discussion': ConversationBotDiscussionZone( + BotMode.discussion: ConversationBotDiscussionZone( initialBotOptions: initialBotOptions, onChanged: onChanged, ), - "custom": ConversationBotCustomZone( - initialBotOptions: initialBotOptions, - onChanged: onChanged, - ), - // "conversation": const ConversationBotConversationZone(), - "text_adventure": ConversationBotTextAdventureZone( + BotMode.custom: ConversationBotCustomZone( initialBotOptions: initialBotOptions, onChanged: onChanged, ), }; + if (!zoneMap.containsKey(initialBotOptions.mode)) { + return Container(); + } return Container( decoration: BoxDecoration( border: Border.all( diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart index 5ec435112..753a8a8a8 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -14,13 +15,13 @@ class ConversationBotModeSelect extends StatelessWidget { @override Widget build(BuildContext context) { final Map options = { - "discussion": + BotMode.discussion: L10n.of(context)!.conversationBotModeSelectOption_discussion, - "custom": L10n.of(context)!.conversationBotModeSelectOption_custom, - // "conversation": - // L10n.of(context)!.conversationBotModeSelectOption_conversation, - "text_adventure": - L10n.of(context)!.conversationBotModeSelectOption_textAdventure, + BotMode.custom: L10n.of(context)!.conversationBotModeSelectOption_custom, + // BotMode.textAdventure: + // L10n.of(context)!.conversationBotModeSelectOption_textAdventure, + // BotMode.storyGame: + // L10n.of(context)!.conversationBotModeSelectOption_storyGame, }; return Padding( @@ -38,7 +39,7 @@ class ConversationBotModeSelect extends StatelessWidget { hint: Padding( padding: const EdgeInsets.only(left: 15), child: Text( - options[initialMode ?? "discussion"]!, + options[initialMode ?? BotMode.discussion]!, style: const TextStyle().copyWith( color: Theme.of(context).textTheme.bodyLarge!.color, fontSize: 14, diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart index e4054f4e5..288149d76 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_settings.dart @@ -256,14 +256,16 @@ class ConversationBotSettingsState extends State { }, ); if (confirm == true) { - if (addBot) { - await widget.room?.invite(BotName.byEnvironment); - } else { - await widget.room?.kick(BotName.byEnvironment); - } updateBotOption(() { botOptions = botOptions; }); + final bool isBotRoomMember = + await widget.room?.isBotRoom ?? false; + if (addBot && !isBotRoomMember) { + await widget.room?.invite(BotName.byEnvironment); + } else if (!addBot && isBotRoomMember) { + await widget.room?.kick(BotName.byEnvironment); + } } }, ), diff --git a/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart b/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart index 519245303..b630f608b 100644 --- a/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart +++ b/lib/pangea/widgets/conversation_bot/conversation_bot_settings_form.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_dynamic_zone.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_mode_select.dart'; @@ -65,7 +66,7 @@ class ConversationBotSettingsFormState initialMode: botOptions.mode, onChanged: (String? mode) => { setState(() { - botOptions.mode = mode ?? "discussion"; + botOptions.mode = mode ?? BotMode.discussion; }), }, ), diff --git a/pubspec.lock b/pubspec.lock index bb56964d1..ed344d6a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1305,18 +1305,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: @@ -1417,10 +1417,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.8.0" material_symbols_icons: dependency: "direct main" description: @@ -1442,10 +1442,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.12.0" mgrs_dart: dependency: transitive description: @@ -1682,10 +1682,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.4" platform_detect: dependency: transitive description: @@ -2303,26 +2303,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.0" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.0" timezone: dependency: transitive description: @@ -2615,10 +2615,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.2.1" wakelock_plus: dependency: "direct main" description: From 25a876913260c715df5da7fa9d2062edf8ccf511 Mon Sep 17 00:00:00 2001 From: choreo development Date: Thu, 19 Sep 2024 10:30:21 -0400 Subject: [PATCH 2/7] turned off showUseType feature --- .../pangea_message_event.dart | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index ce09f003a..88c4c1acf 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -5,7 +5,6 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/enum/audio_encoding_enum.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; @@ -15,7 +14,6 @@ import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; -import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -547,13 +545,14 @@ class PangeaMessageEvent { } } - bool get showUseType => - !ownMessage && - _event.room.isSpaceAdmin && - _event.senderId != BotName.byEnvironment && - !room.isUserSpaceAdmin(_event.senderId) && - _event.messageType != PangeaEventTypes.report && - _event.messageType == MessageTypes.Text; + bool get showUseType => false; + // *note* turning this feature off but leave code here to bring back (if need) + // !ownMessage && + // _event.room.isSpaceAdmin && + // _event.senderId != BotName.byEnvironment && + // !room.isUserSpaceAdmin(_event.senderId) && + // _event.messageType != PangeaEventTypes.report && + // _event.messageType == MessageTypes.Text; // this is just showActivityIcon now but will include // logic for showing From 89678de541831f2db6ea99ee1237559fd6d91c60 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Mon, 23 Sep 2024 16:33:16 -0400 Subject: [PATCH 3/7] temporarily remove job that checkouts out to development to mirror staging with prod on web --- .github/workflows/main_deploy.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main_deploy.yaml b/.github/workflows/main_deploy.yaml index 9439b75ad..8256afff7 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -10,15 +10,15 @@ env: WEB_APP_ENV: ${{ vars.WEB_APP_ENV }} jobs: - switch-branch: - runs-on: ubuntu-latest + # switch-branch: + # runs-on: ubuntu-latest - steps: - - name: Checkout main branch - uses: actions/checkout@v3 + # steps: + # - name: Checkout main branch + # uses: actions/checkout@v3 - - name: Checkout different branch - run: git checkout development + # - name: Checkout different branch + # run: git checkout development build_web: runs-on: ubuntu-latest From a1f9e6a24320571386364a8dadf349d47a73c209 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:01:58 -0400 Subject: [PATCH 4/7] Toolbar practice (#702) * drafting toolbar with practice * moved some code around * turning overlay message content into text buttons for selection, updated toolbar buttons progress bar * activities displaying and forwarding toolbar * experimenting with using choice value rather than index for logic * reimplementation of wordnet results and translation for individual words * cache and timer * mostly done with activities in toolbar flow --------- Co-authored-by: ggurdin Co-authored-by: choreo development --- assets/l10n/intl_en.arb | 2 +- assets/l10n/intl_es.arb | 2 +- lib/main.dart | 2 +- lib/pages/chat/chat.dart | 60 ++- lib/pages/chat/events/html_message.dart | 7 - lib/pages/chat/events/message.dart | 29 +- lib/pages/chat/events/message_content.dart | 31 +- .../controllers/alternative_translator.dart | 49 +- .../controllers/choreographer.dart | 20 +- .../choreographer/widgets/choice_array.dart | 69 +-- lib/pangea/choreographer/widgets/it_bar.dart | 4 +- .../widgets/translation_finished_flow.dart | 2 +- lib/pangea/constants/analytics_constants.dart | 2 +- lib/pangea/controllers/base_controller.dart | 8 +- lib/pangea/controllers/class_controller.dart | 2 +- .../controllers/get_analytics_controller.dart | 5 +- .../controllers/message_data_controller.dart | 346 ++++---------- .../controllers/my_analytics_controller.dart | 166 ++++--- lib/pangea/controllers/pangea_controller.dart | 2 +- ...actice_activity_generation_controller.dart | 53 ++- .../controllers/subscription_controller.dart | 6 +- .../controllers/word_net_controller.dart | 2 +- lib/pangea/enum/message_mode_enum.dart | 52 +- .../pangea_message_event.dart | 130 +++-- .../pangea_representation_event.dart | 35 +- .../practice_activity_event.dart | 10 +- .../analytics/construct_list_model.dart | 89 ++-- .../models/analytics/constructs_model.dart | 21 +- lib/pangea/models/choreo_record.dart | 1 + lib/pangea/models/igc_text_data_model.dart | 40 +- lib/pangea/models/pangea_token_model.dart | 93 ++++ .../message_activity_request.dart | 150 ++++++ .../multiple_choice_activity_model.dart | 25 +- .../practice_activity_model.dart | 75 ++- .../practice_activity_record_model.dart | 6 +- .../models/tokens_event_content_model.dart | 11 +- lib/pangea/network/urls.dart | 3 + .../pages/analytics/construct_list.dart | 15 +- .../repo/contextualized_translation_repo.dart | 7 +- .../repo/full_text_translation_repo.dart | 83 +++- lib/pangea/repo/tokens_repo.dart | 14 + lib/pangea/utils/logout.dart | 3 +- .../widgets/chat/message_audio_card.dart | 4 +- .../chat/message_selection_overlay.dart | 198 ++++++-- .../widgets/chat/message_text_selection.dart | 41 -- lib/pangea/widgets/chat/message_toolbar.dart | 450 +++++++++--------- .../chat/message_translation_card.dart | 105 ++-- .../chat/message_unsubscribed_card.dart | 2 +- .../widgets/chat/overlay_message_text.dart | 147 ++++++ .../analytics_summary/analytics_popup.dart | 14 +- .../learning_progress_indicators.dart | 5 +- lib/pangea/widgets/igc/pangea_rich_text.dart | 3 - lib/pangea/widgets/igc/span_card.dart | 2 +- lib/pangea/widgets/igc/word_data_card.dart | 1 + ...=> generate_practice_activity_button.dart} | 43 +- .../multiple_choice_activity.dart | 59 ++- .../practice_activity_card.dart | 431 ++++++++++++----- .../practice_activity_content.dart | 44 -- .../user_settings/p_language_dialog.dart | 2 +- 59 files changed, 2075 insertions(+), 1208 deletions(-) create mode 100644 lib/pangea/models/practice_activities.dart/message_activity_request.dart delete mode 100644 lib/pangea/widgets/chat/message_text_selection.dart create mode 100644 lib/pangea/widgets/chat/overlay_message_text.dart rename lib/pangea/widgets/practice_activity/{generate_practice_activity.dart => generate_practice_activity_button.dart} (57%) delete mode 100644 lib/pangea/widgets/practice_activity/practice_activity_content.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 7e7a96d45..158495497 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3968,7 +3968,7 @@ "seeOptions": "See options", "continuedWithoutSubscription": "Continue without subscribing", "trialPeriodExpired": "Your trial period has expired", - "selectToDefine": "Highlight a word or phrase to see its definition!", + "selectToDefine": "Click a word to see its definition!", "translations": "translations", "messageAudio": "message audio", "definitions": "definitions", diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index fb9c91df6..cfdc42cb8 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4505,7 +4505,7 @@ "age": {} } }, - "selectToDefine": "¡Resalta una palabra o frase para ver su definición!", + "selectToDefine": "Clic una palabra para definirla", "kickBotWarning": "Patear Pangea Bot eliminará el bot de conversación de este chat.", "activateTrial": "Activar prueba gratuita", "refresh": "Actualizar", diff --git a/lib/main.dart b/lib/main.dart index 6be6edc91..36add47dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env"); + await dotenv.load(fileName: ".env.local_choreo"); } catch (e) { Logs().e('Failed to load .env file', e); } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 12fc40af1..b6256fc03 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -15,6 +15,7 @@ import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; @@ -27,7 +28,6 @@ import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/utils/overlay.dart'; import 'package:fluffychat/pangea/utils/report_message.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_text_controller.dart'; import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/utils/error_reporter.dart'; @@ -654,14 +654,14 @@ class ChatController extends State // stream sends the data for newly sent messages. if (msgEventId != null) { pangeaController.myAnalytics.setState( - data: { - 'eventID': msgEventId, - 'eventType': EventTypes.Message, - 'roomID': room.id, - 'originalSent': originalSent, - 'tokensSent': tokensSent, - 'choreo': choreo, - }, + AnalyticsStream( + eventId: msgEventId, + eventType: EventTypes.Message, + roomId: room.id, + originalSent: originalSent, + tokensSent: tokensSent, + choreo: choreo, + ), ); } @@ -1303,8 +1303,7 @@ class ChatController extends State /// text and selection stored for the text in that overlay void closeSelectionOverlay() { MatrixState.pAnyState.closeAllOverlays(); - textSelection.clearMessageText(); - textSelection.onSelection(null); + // selectedTokenIndicies.clear(); } // Pangea# @@ -1610,8 +1609,6 @@ class ChatController extends State }); // #Pangea - final textSelection = MessageTextSelection(); - void showToolbar( PangeaMessageEvent pangeaMessageEvent, { MessageMode? mode, @@ -1643,10 +1640,9 @@ class ChatController extends State Widget? overlayEntry; try { overlayEntry = MessageSelectionOverlay( - controller: this, + chatController: this, event: pangeaMessageEvent.event, pangeaMessageEvent: pangeaMessageEvent, - textSelection: textSelection, nextEvent: nextEvent, prevEvent: prevEvent, ); @@ -1671,7 +1667,39 @@ class ChatController extends State onSelectMessage(pangeaMessageEvent.event); HapticFeedback.mediumImpact(); } - // Pangea# + + // final List selectedTokenIndicies = []; + // void onClickOverlayMessageToken( + // PangeaMessageEvent pangeaMessageEvent, + // int tokenIndex, + // ) { + // if (pangeaMessageEvent.originalSent?.tokens == null || + // tokenIndex < 0 || + // tokenIndex >= pangeaMessageEvent.originalSent!.tokens!.length) { + // selectedTokenIndicies.clear(); + // return; + // } + + // // if there's stuff that's already selected, then we already ahve a sentence deselect + // if (selectedTokenIndicies.isNotEmpty) { + // final bool listContainedIndex = + // selectedTokenIndicies.contains(tokenIndex); + + // selectedTokenIndicies.clear(); + // if (!listContainedIndex) { + // selectedTokenIndicies.add(tokenIndex); + // } + // } + + // // TODO + // // if this is already selected, see if there's sentnence and selelct that + + // // if nothing is select, select one token + // else { + // selectedTokenIndicies.add(tokenIndex); + // } + // } + // // Pangea# late final ValueNotifier displayChatDetailsColumn; diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index 4bdb0fe35..d06fa96c0 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -5,7 +5,6 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_highlighter/flutter_highlighter.dart'; import 'package:flutter_highlighter/themes/shades-of-purple.dart'; import 'package:flutter_html/flutter_html.dart'; @@ -75,9 +74,6 @@ class HtmlMessage extends StatelessWidget { @override Widget build(BuildContext context) { - // #Pangea - controller.textSelection.setMessageText(html); - // Pangea# final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; final linkColor = textColor.withAlpha(150); @@ -97,9 +93,6 @@ class HtmlMessage extends StatelessWidget { // there is no need to pre-validate the html, as we validate it while rendering // #Pangea return SelectionArea( - onSelectionChanged: (SelectedContent? selection) { - controller.textSelection.onSelection(selection?.plainText); - }, child: GestureDetector( onTap: () { if (pangeaMessageEvent != null && !isOverlay) { diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 32208bb5f..6147a66df 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/enum/use_type.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/widgets/chat/message_buttons.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -39,7 +40,7 @@ class Message extends StatelessWidget { // #Pangea final bool immersionMode; final ChatController controller; - final bool isOverlay; + final MessageOverlayController? overlayController; // Pangea# final Color? avatarPresenceBackgroundColor; @@ -63,14 +64,15 @@ class Message extends StatelessWidget { // #Pangea required this.immersionMode, required this.controller, - this.isOverlay = false, + this.overlayController, // Pangea# super.key, }); // #Pangea void showToolbar(PangeaMessageEvent? pangeaMessageEvent) { - if (pangeaMessageEvent != null && !isOverlay) { + // if overlayController is not null, the message is already in overlay mode + if (pangeaMessageEvent != null && overlayController == null) { controller.showToolbar( pangeaMessageEvent, nextEvent: nextEvent, @@ -83,7 +85,6 @@ class Message extends StatelessWidget { @override Widget build(BuildContext context) { // #Pangea - debugPrint('Message.build()'); PangeaMessageEvent? pangeaMessageEvent; if (event.type == EventTypes.Message) { pangeaMessageEvent = PangeaMessageEvent( @@ -239,7 +240,9 @@ class Message extends StatelessWidget { // ), // ) // else if (nextEventSameSender || ownMessage) - if (nextEventSameSender || ownMessage || isOverlay) + if (nextEventSameSender || + ownMessage || + overlayController != null) // Pangea# SizedBox( width: Avatar.defaultSize, @@ -281,7 +284,8 @@ class Message extends StatelessWidget { children: [ // #Pangea // if (!nextEventSameSender) - if (!nextEventSameSender && !isOverlay) + if (!nextEventSameSender && + overlayController == null) // Pangea# Padding( padding: const EdgeInsets.only( @@ -348,14 +352,14 @@ class Message extends StatelessWidget { ), // #Pangea child: CompositedTransformTarget( - link: isOverlay + link: overlayController != null ? LayerLinkAndKey('overlay_msg') .link : MatrixState.pAnyState .layerLinkAndKey(event.eventId) .link, child: Container( - key: isOverlay + key: overlayController != null ? LayerLinkAndKey('overlay_msg') .key : MatrixState.pAnyState @@ -448,7 +452,8 @@ class Message extends StatelessWidget { pangeaMessageEvent: pangeaMessageEvent, immersionMode: immersionMode, - isOverlay: isOverlay, + overlayController: + overlayController, controller: controller, nextEvent: nextEvent, prevEvent: previousEvent, @@ -536,7 +541,7 @@ class Message extends StatelessWidget { event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); // #Pangea // if (showReceiptsRow || displayTime || selected || displayReadMarker) { - if (!isOverlay && + if (overlayController == null && (showReceiptsRow || displayTime || displayReadMarker || @@ -577,7 +582,7 @@ class Message extends StatelessWidget { duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, // #Pangea - child: isOverlay || + child: overlayController != null || (!showReceiptsRow && !(pangeaMessageEvent?.showMessageButtons ?? false)) // child: !showReceiptsRow @@ -670,7 +675,7 @@ class Message extends StatelessWidget { top: nextEventSameSender ? 1.0 : 4.0, bottom: // #Pangea - isOverlay + overlayController != null ? 0 : // Pangea# diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index f0057289d..eca0cfd5f 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -3,7 +3,9 @@ import 'dart:math'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/overlay_message_text.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; @@ -35,7 +37,7 @@ class MessageContent extends StatelessWidget { //here rather than passing the choreographer? pangea rich text, a widget //further down in the chain is also using pangeaController so its not constant final bool immersionMode; - final bool isOverlay; + final MessageOverlayController? overlayController; final ChatController controller; final Event? nextEvent; final Event? prevEvent; @@ -49,7 +51,7 @@ class MessageContent extends StatelessWidget { // #Pangea this.pangeaMessageEvent, required this.immersionMode, - this.isOverlay = false, + this.overlayController, required this.controller, this.nextEvent, this.prevEvent, @@ -121,6 +123,7 @@ class MessageContent extends StatelessWidget { @override Widget build(BuildContext context) { + // debugger(when: overlayController != null); final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; final buttonTextColor = textColor; switch (event.type) { @@ -208,7 +211,7 @@ class MessageContent extends StatelessWidget { textColor: textColor, room: event.room, // #Pangea - isOverlay: isOverlay, + isOverlay: overlayController != null, controller: controller, pangeaMessageEvent: pangeaMessageEvent, nextEvent: nextEvent, @@ -303,26 +306,26 @@ class MessageContent extends StatelessWidget { decoration: event.redacted ? TextDecoration.lineThrough : null, height: 1.3, ); + + // debugger(when: overlayController != null); + if (overlayController != null && pangeaMessageEvent != null) { + return OverlayMessageText( + pangeaMessageEvent: pangeaMessageEvent!, + overlayController: overlayController!, + ); + } + if (immersionMode && pangeaMessageEvent != null) { return Flexible( child: PangeaRichText( style: messageTextStyle, pangeaMessageEvent: pangeaMessageEvent!, immersionMode: immersionMode, - isOverlay: isOverlay, + isOverlay: overlayController != null, controller: controller, ), ); } - - if (isOverlay) { - controller.textSelection.setMessageText( - event.calcLocalizedBodyFallback( - MatrixLocals(L10n.of(context)!), - hideReply: true, - ), - ); - } // Pangea# return @@ -330,7 +333,7 @@ class MessageContent extends StatelessWidget { ToolbarSelectionArea( controller: controller, pangeaMessageEvent: pangeaMessageEvent, - isOverlay: isOverlay, + isOverlay: overlayController != null, nextEvent: nextEvent, prevEvent: prevEvent, child: diff --git a/lib/pangea/choreographer/controllers/alternative_translator.dart b/lib/pangea/choreographer/controllers/alternative_translator.dart index 7ac564087..1e570c7af 100644 --- a/lib/pangea/choreographer/controllers/alternative_translator.dart +++ b/lib/pangea/choreographer/controllers/alternative_translator.dart @@ -33,45 +33,6 @@ class AlternativeTranslator { similarityResponse = null; } - // void onSeeAlternativeTranslationsTap() { - // if (choreographer.itController.sourceText == null) { - // ErrorHandler.logError( - // m: "sourceText null in onSeeAlternativeTranslationsTap", - // s: StackTrace.current, - // ); - // choreographer.itController.closeIT(); - // return; - // } - // showAlternativeTranslations = true; - // loadingAlternativeTranslations = true; - // translate(choreographer.itController.sourceText!); - // choreographer.setState(); - // } - - // Future translate(String text) async { - // throw Exception('disabled translaations'); - // try { - // final FullTextTranslationResponseModel results = - // await FullTextTranslationRepo.translate( - // accessToken: await choreographer.accessToken, - // request: FullTextTranslationRequestModel( - // text: text, - // tgtLang: choreographer.l2LangCode!, - // userL2: choreographer.l2LangCode!, - // userL1: choreographer.l1LangCode!, - // ), - // ); - // // translations = results.translations; - // } catch (err, stack) { - // showAlternativeTranslations = false; - // debugger(when: kDebugMode); - // ErrorHandler.logError(e: err, s: stack); - // } finally { - // loadingAlternativeTranslations = false; - // choreographer.setState(); - // } - // } - Future setTranslationFeedback() async { try { choreographer.startLoading(); @@ -155,20 +116,20 @@ class AlternativeTranslator { } switch (translationFeedbackKey) { case FeedbackKey.allCorrect: - return "Score: 100%\n${L10n.of(context)!.allCorrect}"; + return "Match: 100%\n${L10n.of(context)!.allCorrect}"; case FeedbackKey.newWayAllGood: - return "Score: 100%\n${L10n.of(context)!.newWayAllGood}"; + return "Match: 100%\n${L10n.of(context)!.newWayAllGood}"; case FeedbackKey.othersAreBetter: final num userScore = (similarityResponse!.userScore(userTranslation!) * 100).round(); final String displayScore = userScore.toString(); if (userScore > 90) { - return "Score: $displayScore%\n${L10n.of(context)!.almostPerfect}"; + return "Match: $displayScore%\n${L10n.of(context)!.almostPerfect}"; } if (userScore > 80) { - return "Score: $displayScore%\n${L10n.of(context)!.prettyGood}"; + return "Match: $displayScore%\n${L10n.of(context)!.prettyGood}"; } - return "Score: $displayScore%\n${L10n.of(context)!.othersAreBetter}"; + return "Match: $displayScore%\n${L10n.of(context)!.othersAreBetter}"; // case FeedbackKey.commonalityFeedback: // final int count = controller.completedITSteps // .where((element) => element.isCorrect) diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 1a11de0e3..389c5fad5 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/enum/assistance_state_enum.dart'; import 'package:fluffychat/pangea/enum/edit_type.dart'; import 'package:fluffychat/pangea/models/it_step.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; @@ -103,11 +104,28 @@ class Choreographer { ) : null; + // we've got a rather elaborate method of updating tokens after matches are accepted + // so we need to check if the reconstructed text matches the current text + // if not, let's get the tokens again and log an error + if (igc.igcTextData?.tokens != null && + PangeaToken.reconstructText(igc.igcTextData!.tokens) != currentText) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "reconstructed text does not match current text", + s: StackTrace.current, + data: { + "igcTextData": igc.igcTextData?.toJson(), + "choreoRecord": choreoRecord.toJson(), + }, + ); + await igc.getIGCTextData(onlyTokensAndLanguageDetection: true); + } + // TODO - why does both it and igc need to be enabled for choreo to be applicable? // final ChoreoRecord? applicableChoreo = // isITandIGCEnabled && igc.igcTextData != null ? choreoRecord : null; - // if tokens or language detection are not available, we should get them + // if tokens OR language detection are not available, we should get them // notes // 1) we probably need to move this to after we clear the input field // or the user could experience some lag here. diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index adf30dcb1..2bf48b08a 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -8,16 +9,18 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import '../../utils/bot_style.dart'; import 'it_shimmer.dart'; +typedef ChoiceCallback = void Function(String value, int index); + class ChoicesArray extends StatefulWidget { final bool isLoading; final List? choices; - final void Function(int) onPressed; - final void Function(int)? onLongPress; + final ChoiceCallback onPressed; + final ChoiceCallback? onLongPress; final int? selectedChoiceIndex; final String originalSpan; final String Function(int) uniqueKeyForLayerLink; - /// some uses of this widget want to disable the choices + /// some uses of this widget want to disable clicking of the choices final bool isActive; const ChoicesArray({ @@ -63,20 +66,22 @@ class ChoicesArrayState extends State { ? ItShimmer(originalSpan: widget.originalSpan) : Wrap( alignment: WrapAlignment.center, - children: widget.choices - ?.asMap() - .entries - .map( - (entry) => ChoiceItem( + children: widget.choices! + .mapIndexed( + (index, entry) => ChoiceItem( theme: theme, onLongPress: widget.isActive ? widget.onLongPress : null, - onPressed: widget.isActive ? widget.onPressed : (_) {}, - entry: entry, + onPressed: widget.isActive + ? widget.onPressed + : (String value, int index) { + debugger(when: kDebugMode); + }, + entry: MapEntry(index, entry), interactionDisabled: interactionDisabled, enableInteraction: enableInteractions, disableInteraction: disableInteraction, - isSelected: widget.selectedChoiceIndex == entry.key, + isSelected: widget.selectedChoiceIndex == index, ), ) .toList() ?? @@ -112,8 +117,8 @@ class ChoiceItem extends StatelessWidget { final MapEntry entry; final ThemeData theme; - final void Function(int p1)? onLongPress; - final void Function(int p1) onPressed; + final ChoiceCallback? onLongPress; + final ChoiceCallback onPressed; final bool isSelected; final bool interactionDisabled; final VoidCallback enableInteraction; @@ -136,27 +141,28 @@ class ChoiceItem extends StatelessWidget { child: Container( margin: const EdgeInsets.all(2), padding: EdgeInsets.zero, - decoration: isSelected - ? BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: Border.all( - color: entry.value.color ?? theme.colorScheme.primary, - style: BorderStyle.solid, - width: 2.0, - ), - ) - : null, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + border: Border.all( + color: isSelected + ? entry.value.color ?? theme.colorScheme.primary + : Colors.transparent, + style: BorderStyle.solid, + width: 2.0, + ), + ), child: TextButton( style: ButtonStyle( padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 7), ), //if index is selected, then give the background a slight primary color - backgroundColor: WidgetStateProperty.all( - entry.value.color != null - ? entry.value.color!.withOpacity(0.2) - : theme.colorScheme.primary.withOpacity(0.1), - ), + backgroundColor: entry.value.color != null + ? WidgetStateProperty.all( + entry.value.color!.withOpacity(0.2), + ) + // : theme.colorScheme.primaryFixed, + : null, textStyle: WidgetStateProperty.all( BotStyle.text(context), ), @@ -167,10 +173,11 @@ class ChoiceItem extends StatelessWidget { ), ), onLongPress: onLongPress != null && !interactionDisabled - ? () => onLongPress!(entry.key) + ? () => onLongPress!(entry.value.text, entry.key) : null, - onPressed: - interactionDisabled ? null : () => onPressed(entry.key), + onPressed: interactionDisabled + ? null + : () => onPressed(entry.value.text, entry.key), child: Text( entry.value.text, style: BotStyle.text(context), diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 78e199ca1..768783dd0 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -393,8 +393,8 @@ class ITChoices extends StatelessWidget { return Choice(text: "error", color: Colors.red); } }).toList(), - onPressed: (int index) => selectContinuance(index, context), - onLongPress: (int index) => showCard(context, index), + onPressed: (value, index) => selectContinuance(index, context), + onLongPress: (value, index) => showCard(context, index), uniqueKeyForLayerLink: (int index) => "itChoices$index", selectedChoiceIndex: null, ); diff --git a/lib/pangea/choreographer/widgets/translation_finished_flow.dart b/lib/pangea/choreographer/widgets/translation_finished_flow.dart index de49be145..fe6630af5 100644 --- a/lib/pangea/choreographer/widgets/translation_finished_flow.dart +++ b/lib/pangea/choreographer/widgets/translation_finished_flow.dart @@ -75,7 +75,7 @@ class AlternativeTranslations extends StatelessWidget { Choice(text: controller.choreographer.altTranslator.translations.first), ], // choices: controller.choreographer.altTranslator.translations, - onPressed: (int index) { + onPressed: (String value, int index) { controller.choreographer.onSelectAlternativeTranslation( controller.choreographer.altTranslator.translations[index], ); diff --git a/lib/pangea/constants/analytics_constants.dart b/lib/pangea/constants/analytics_constants.dart index fb7c356f8..f9893d638 100644 --- a/lib/pangea/constants/analytics_constants.dart +++ b/lib/pangea/constants/analytics_constants.dart @@ -1,5 +1,5 @@ class AnalyticsConstants { - static const int xpPerLevel = 2000; + static const int xpPerLevel = 500; static const int vocabUseMaxXP = 30; static const int morphUseMaxXP = 500; } diff --git a/lib/pangea/controllers/base_controller.dart b/lib/pangea/controllers/base_controller.dart index ce41353e8..69939e50a 100644 --- a/lib/pangea/controllers/base_controller.dart +++ b/lib/pangea/controllers/base_controller.dart @@ -1,8 +1,8 @@ import 'dart:async'; -class BaseController { - final StreamController stateListener = StreamController(); - late Stream stateStream; +class BaseController { + final StreamController stateListener = StreamController(); + late Stream stateStream; BaseController() { stateStream = stateListener.stream.asBroadcastStream(); @@ -12,7 +12,7 @@ class BaseController { stateListener.close(); } - setState({dynamic data}) { + setState(T data) { stateListener.add(data); } } diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index 84f17a5e4..157a11b59 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -28,7 +28,7 @@ class ClassController extends BaseController { } setActiveSpaceIdInChatListController(String? classId) { - setState(data: {"activeSpaceId": classId}); + setState({"activeSpaceId": classId}); } /// For all the spaces that the user is teaching, set the power levels diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index e23b30d1e..9a6a934a1 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/local.key.dart'; import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; @@ -42,7 +41,9 @@ class GetAnalyticsController { int get serverXP => currentXP - localXP; /// Get the current level based on the number of xp points - int get level => currentXP ~/ AnalyticsConstants.xpPerLevel; + /// The formula is calculated from XP and modeled on RPG games + // int get level => 1 + sqrt((1 + 8 * currentXP / 100) / 2).floor(); + int get level => currentXP ~/ 10; void initialize() { _analyticsUpdateSubscription ??= _pangeaController diff --git a/lib/pangea/controllers/message_data_controller.dart b/lib/pangea/controllers/message_data_controller.dart index 9903eada0..a26c558ba 100644 --- a/lib/pangea/controllers/message_data_controller.dart +++ b/lib/pangea/controllers/message_data_controller.dart @@ -1,285 +1,145 @@ -import 'package:collection/collection.dart'; +import 'dart:async'; + import 'package:fluffychat/pangea/controllers/base_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/repo/tokens_repo.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; import '../constants/pangea_event_types.dart'; -import '../enum/use_type.dart'; -import '../models/choreo_record.dart'; import '../repo/full_text_translation_repo.dart'; import '../utils/error_handler.dart'; +// TODO - make this static and take it out of the _pangeaController +// will need to pass accessToken to the requests class MessageDataController extends BaseController { late PangeaController _pangeaController; - final List _cache = []; - final List _representationCache = []; + final Map>> _tokensCache = {}; + final Map> _representationCache = {}; + late Timer _cacheTimer; MessageDataController(PangeaController pangeaController) { _pangeaController = pangeaController; + _startCacheTimer(); } - CacheItem? getItem(String parentId, String type, String langCode) => - _cache.firstWhereOrNull( - (e) => - e.parentId == parentId && e.type == type && e.langCode == langCode, - ); - - RepresentationCacheItem? getRepresentationCacheItem( - String parentId, - String langCode, - ) => - _representationCache.firstWhereOrNull( - (e) => e.parentId == parentId && e.langCode == langCode, - ); - - Future _getTokens( - TokensRequestModel req, - ) async { - final accessToken = _pangeaController.userController.accessToken; - - final TokensResponseModel igcTextData = - await TokensRepo.tokenize(accessToken, req); - - return PangeaMessageTokens(tokens: igcTextData.tokens); + /// Starts a timer that clears the cache every 10 minutes + void _startCacheTimer() { + _cacheTimer = Timer.periodic(const Duration(minutes: 10), (timer) { + _clearCache(); + }); } - Future _getTokenEvent({ - required BuildContext context, - required String repEventId, + /// Clears the token and representation caches + void _clearCache() { + _tokensCache.clear(); + _representationCache.clear(); + debugPrint("message data cache cleared."); + } + + @override + void dispose() { + _cacheTimer.cancel(); // Cancel the timer when the controller is disposed + super.dispose(); + } + + /// get tokens from the server + /// if repEventId is not null, send the tokens to the room + Future> _getTokens({ + required String? repEventId, required TokensRequestModel req, - required Room room, + required Room? room, }) async { - try { - final PangeaMessageTokens? pangeaMessageTokens = await _getTokens( - req, - ); - if (pangeaMessageTokens == null) return null; - - final Event? tokensEvent = await room.sendPangeaEvent( - content: pangeaMessageTokens.toJson(), - parentEventId: repEventId, - type: PangeaEventTypes.tokens, - ); - - return tokensEvent; - } catch (err, stack) { - Sentry.addBreadcrumb( - Breadcrumb( - message: "err in _getTokenEvent with repEventId $repEventId", - ), - ); - Sentry.addBreadcrumb( - Breadcrumb.fromJson({"req": req.toJson()}), - ); - Sentry.addBreadcrumb( - Breadcrumb.fromJson({"room": room.toJson()}), - ); - ErrorHandler.logError(e: err, s: stack); - return null; - } - } - - Future getTokenEvent({ - required BuildContext context, - required String repEventId, - required TokensRequestModel req, - required Room room, - }) async { - final CacheItem? item = - getItem(repEventId, PangeaEventTypes.tokens, req.userL2); - if (item != null) return item.data; - - _cache.add( - CacheItem( - repEventId, - PangeaEventTypes.tokens, - req.userL2, - _getTokenEvent( - context: context, - repEventId: repEventId, - req: req, - room: room, - ), - ), + final TokensResponseModel res = await TokensRepo.tokenize( + _pangeaController.userController.accessToken, + req, ); + if (repEventId != null && room != null) { + room + .sendPangeaEvent( + content: PangeaMessageTokens(tokens: res.tokens).toJson(), + parentEventId: repEventId, + type: PangeaEventTypes.tokens, + ) + .catchError( + (e) => ErrorHandler.logError( + m: "error in _getTokens.sendPangeaEvent", + e: e, + s: StackTrace.current, + data: req.toJson(), + ), + ); + } - return _cache.last.data; + return res.tokens; } + /// get tokens from the server + /// first check if the tokens are in the cache + /// if repEventId is not null, send the tokens to the room + Future> getTokens({ + required String? repEventId, + required TokensRequestModel req, + required Room? room, + }) => + _tokensCache[req.hashCode] ??= _getTokens( + repEventId: repEventId, + req: req, + room: room, + ); + /////// translation //////// - /// make representation (originalSent and originalWritten always false) - Future _sendRepresentationMatrixEvent({ - required PangeaRepresentation representation, - required String messageEventId, - required Room room, + /// get translation from the server + /// if in cache, return from cache + /// if not in cache, get from server + /// send the translation to the room as a representation event + Future getPangeaRepresentation({ + required FullTextTranslationRequestModel req, + required Event messageEvent, }) async { - try { - final Event? repEvent = await room.sendPangeaEvent( - content: representation.toJson(), - parentEventId: messageEventId, - type: PangeaEventTypes.representation, - ); - - return repEvent; - } catch (err, stack) { - Sentry.addBreadcrumb( - Breadcrumb( - message: - "err in _sendRepresentationMatrixEvent with messageEventId $messageEventId", - ), - ); - Sentry.addBreadcrumb( - Breadcrumb.fromJson({"room": room.toJson()}), - ); - ErrorHandler.logError(e: err, s: stack); - return null; - } + return _representationCache[req.hashCode] ??= + _getPangeaRepresentation(req: req, messageEvent: messageEvent); } - Future getPangeaRepresentation({ - required String text, - required String? source, - required String target, - required Room room, + Future _getPangeaRepresentation({ + required FullTextTranslationRequestModel req, + required Event messageEvent, }) async { - final RepresentationCacheItem? item = - getRepresentationCacheItem(text, target); - if (item != null) return item.data; - - _representationCache.add( - RepresentationCacheItem( - text, - target, - _getPangeaRepresentation( - text: text, - source: source, - target: target, - room: room, - ), - ), + final FullTextTranslationResponseModel res = + await FullTextTranslationRepo.translate( + accessToken: _pangeaController.userController.accessToken, + request: req, ); - return _representationCache.last.data; - } - - Future _getPangeaRepresentation({ - required String text, - required String? source, - required String target, - required Room room, - }) async { - if (_pangeaController.languageController.userL2 == null || - _pangeaController.languageController.userL1 == null) { - ErrorHandler.logError( - e: "userL1 or userL2 is null in _getPangeaRepresentation", - s: StackTrace.current, - ); - return null; - } - final req = FullTextTranslationRequestModel( - text: text, - tgtLang: target, - srcLang: source, - userL2: _pangeaController.languageController.userL2!.langCode, - userL1: _pangeaController.languageController.userL1!.langCode, + final rep = PangeaRepresentation( + langCode: req.tgtLang, + text: res.bestTranslation, + originalSent: false, + originalWritten: false, ); - try { - final FullTextTranslationResponseModel res = - await FullTextTranslationRepo.translate( - accessToken: _pangeaController.userController.accessToken, - request: req, - ); + messageEvent.room + .sendPangeaEvent( + content: rep.toJson(), + parentEventId: messageEvent.eventId, + type: PangeaEventTypes.representation, + ) + .catchError( + (e) => ErrorHandler.logError( + m: "error in _getPangeaRepresentation.sendPangeaEvent", + e: e, + s: StackTrace.current, + data: req.toJson(), + ), + ); - return PangeaRepresentation( - langCode: req.tgtLang, - text: res.bestTranslation, - originalSent: false, - originalWritten: false, - ); - } catch (err, stack) { - ErrorHandler.logError(e: err, s: stack); - return null; - } - } - - /// make representation (originalSent and originalWritten always false) - Future sendRepresentationMatrixEvent({ - required PangeaRepresentation representation, - required String messageEventId, - required Room room, - required String target, - }) async { - final CacheItem? item = - getItem(messageEventId, PangeaEventTypes.representation, target); - if (item != null) return item.data; - - _cache.add( - CacheItem( - messageEventId, - PangeaEventTypes.representation, - target, - _sendRepresentationMatrixEvent( - messageEventId: messageEventId, - room: room, - representation: representation, - ), - ), - ); - - return _cache.last.data; + return rep; } } - -class MessageDataQueueItem { - String transactionId; - - List repTokensAndRecords; - - UseType useType; - - MessageDataQueueItem( - this.transactionId, - this.repTokensAndRecords, - this.useType, - // required this.recentMessageRecord, - ); -} - -class RepTokensAndRecord { - PangeaRepresentation representation; - ChoreoRecord? choreoRecord; - PangeaMessageTokens? tokens; - RepTokensAndRecord(this.representation, this.choreoRecord, this.tokens); - - Map toJson() => { - "rep": representation.toJson(), - "choreoRecord": choreoRecord?.toJson(), - "tokens": tokens?.toJson(), - }; -} - -class CacheItem { - String parentId; - String langCode; - String type; - Future data; - - CacheItem(this.parentId, this.type, this.langCode, this.data); -} - -class RepresentationCacheItem { - String parentId; - String langCode; - Future data; - - RepresentationCacheItem(this.parentId, this.langCode, this.data); -} diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index d92c2e48d..a856f89db 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -25,11 +25,11 @@ enum AnalyticsUpdateType { server, local } /// handles the processing of analytics for /// 1) messages sent by the user and /// 2) constructs used by the user, both in sending messages and doing practice activities -class MyAnalyticsController extends BaseController { +class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; CachedStreamController analyticsUpdateStream = CachedStreamController(); - StreamSubscription? _messageSendSubscription; + StreamSubscription? _messageSendSubscription; Timer? _updateTimer; Client get _client => _pangeaController.matrixState.client; @@ -48,7 +48,7 @@ class MyAnalyticsController extends BaseController { final int _maxMessagesCached = 10; /// the number of minutes before an automatic update is triggered - final int _minutesBeforeUpdate = 5; + final int _minutesBeforeUpdate = 2; /// the time since the last update that will trigger an automatic update final Duration _timeSinceUpdate = const Duration(days: 1); @@ -60,9 +60,8 @@ class MyAnalyticsController extends BaseController { void initialize() { // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user - _messageSendSubscription ??= stateStream - .where((data) => data is Map) - .listen((data) => onMessageSent(data as Map)); + _messageSendSubscription ??= + stateStream.listen((data) => _onNewAnalyticsData(data)); _refreshAnalyticsIfOutdated(); } @@ -103,77 +102,59 @@ class MyAnalyticsController extends BaseController { final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate); if (lastUpdated?.isBefore(yesterday) ?? true) { debugPrint("analytics out-of-date, updating"); - await updateAnalytics(); + await sendLocalAnalyticsToAnalyticsRoom(); } } /// Given the data from a newly sent message, format and cache /// the message's construct data locally and reset the update timer - void onMessageSent(Map data) { - // cancel the last timer that was set on message event and - // reset it to fire after _minutesBeforeUpdate minutes - _updateTimer?.cancel(); - _updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () { - debugPrint("timer fired, updating analytics"); - updateAnalytics(); - }); - - // extract the relevant data about this message - final String? eventID = data['eventID']; - final String? roomID = data['roomID']; - final String? eventType = data['eventType']; - final PangeaRepresentation? originalSent = data['originalSent']; - final PangeaMessageTokens? tokensSent = data['tokensSent']; - final ChoreoRecord? choreo = data['choreo']; - final PracticeActivityEvent? practiceActivity = data['practiceActivity']; - final PracticeActivityRecordModel? recordModel = data['recordModel']; - - if (roomID == null || eventID == null) return; - + void _onNewAnalyticsData(AnalyticsStream data) { // convert that data into construct uses and add it to the cache final metadata = ConstructUseMetaData( - roomId: roomID, - eventId: eventID, + roomId: data.roomId, + eventId: data.eventId, timeStamp: DateTime.now(), ); - final List constructs = getDraftUses(roomID); + final List constructs = _getDraftUses(data.roomId); - if (eventType == EventTypes.Message) { - final grammarConstructs = - choreo?.grammarConstructUses(metadata: metadata); - final vocabUses = tokensSent != null - ? originalSent?.vocabUses( - choreo: choreo, - tokens: tokensSent.tokens, - metadata: metadata, - ) - : null; + if (data.eventType == EventTypes.Message) { constructs.addAll([ - ...(grammarConstructs ?? []), - ...(vocabUses ?? []), + ...(data.choreo!.grammarConstructUses(metadata: metadata)), + ...(data.originalSent!.vocabUses( + choreo: data.choreo, + tokens: data.tokensSent!.tokens, + metadata: metadata, + )), ]); - } - - if (eventType == PangeaEventTypes.activityRecord && - practiceActivity != null) { - final activityConstructs = recordModel?.uses( - practiceActivity, + } else if (data.eventType == PangeaEventTypes.activityRecord && + data.practiceActivity != null) { + final activityConstructs = data.recordModel!.uses( + data.practiceActivity!, metadata: metadata, ); - constructs.addAll(activityConstructs ?? []); + constructs.addAll(activityConstructs); + } else { + throw PangeaWarningError("Invalid event type for analytics stream"); } + final String eventID = data.eventId; + final String roomID = data.roomId; + _pangeaController.analytics .filterConstructs(unfilteredConstructs: constructs) .then((filtered) { if (filtered.isEmpty) return; - filtered.addAll(getDraftUses(roomID)); + + // @ggurdin - are we sure this isn't happening twice? it's also above + filtered.addAll(_getDraftUses(data.roomId)); + final level = _pangeaController.analytics.level; - addLocalMessage(eventID, filtered).then( + + _addLocalMessage(eventID, filtered).then( (_) { - clearDraftUses(roomID); - afterAddLocalMessages(level); + _clearDraftUses(roomID); + _decideWhetherToUpdateAnalyticsRoom(level); }, ); }); @@ -216,26 +197,28 @@ class MyAnalyticsController extends BaseController { } } + // @ggurdin - if the point of draft uses is that we don't want to send them twice, + // then, if this is triggered here, couldn't that make a problem? final level = _pangeaController.analytics.level; - addLocalMessage('draft$roomID', uses).then( - (_) => afterAddLocalMessages(level), + _addLocalMessage('draft$roomID', uses).then( + (_) => _decideWhetherToUpdateAnalyticsRoom(level), ); } - List getDraftUses(String roomID) { + List _getDraftUses(String roomID) { final currentCache = _pangeaController.analytics.messagesSinceUpdate; return currentCache['draft$roomID'] ?? []; } - void clearDraftUses(String roomID) { + void _clearDraftUses(String roomID) { final currentCache = _pangeaController.analytics.messagesSinceUpdate; currentCache.remove('draft$roomID'); - setMessagesSinceUpdate(currentCache); + _setMessagesSinceUpdate(currentCache); } /// Add a list of construct uses for a new message to the local /// cache of recently sent messages - Future addLocalMessage( + Future _addLocalMessage( String eventID, List constructs, ) async { @@ -244,7 +227,7 @@ class MyAnalyticsController extends BaseController { constructs.addAll(currentCache[eventID] ?? []); currentCache[eventID] = constructs; - await setMessagesSinceUpdate(currentCache); + await _setMessagesSinceUpdate(currentCache); } catch (e, s) { ErrorHandler.logError( e: PangeaWarningError("Failed to add message since update: $e"), @@ -258,17 +241,25 @@ class MyAnalyticsController extends BaseController { /// If the addition brought the total number of messages in the cache /// to the max, or if the addition triggered a level-up, update the analytics. /// Otherwise, add a local update to the alert stream. - void afterAddLocalMessages(int prevLevel) { + void _decideWhetherToUpdateAnalyticsRoom(int prevLevel) { + // cancel the last timer that was set on message event and + // reset it to fire after _minutesBeforeUpdate minutes + _updateTimer?.cancel(); + _updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () { + debugPrint("timer fired, updating analytics"); + sendLocalAnalyticsToAnalyticsRoom(); + }); + if (_pangeaController.analytics.messagesSinceUpdate.length > _maxMessagesCached) { debugPrint("reached max messages, updating"); - updateAnalytics(); + sendLocalAnalyticsToAnalyticsRoom(); return; } final int newLevel = _pangeaController.analytics.level; newLevel > prevLevel - ? updateAnalytics() + ? sendLocalAnalyticsToAnalyticsRoom() : analyticsUpdateStream.add(AnalyticsUpdateType.local); } @@ -278,7 +269,7 @@ class MyAnalyticsController extends BaseController { } /// Save the local cache of recently sent constructs to the local storage - Future setMessagesSinceUpdate( + Future _setMessagesSinceUpdate( Map> cache, ) async { final formattedCache = {}; @@ -302,7 +293,7 @@ class MyAnalyticsController extends BaseController { /// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and /// proceeds with the update process. If the update is successful, it clears any messages that were received /// since the last update and notifies the [analyticsUpdateStream]. - Future updateAnalytics() async { + Future sendLocalAnalyticsToAnalyticsRoom() async { if (_pangeaController.matrixState.client.userID == null) return; if (!(_updateCompleter?.isCompleted ?? true)) { await _updateCompleter!.future; @@ -348,3 +339,46 @@ class MyAnalyticsController extends BaseController { ); } } + +class AnalyticsStream { + final String eventId; + final String eventType; + final String roomId; + + /// if the event is a message, the original message sent + final PangeaRepresentation? originalSent; + + /// if the event is a message, the tokens sent + final PangeaMessageTokens? tokensSent; + + /// if the event is a message, the choreo record + final ChoreoRecord? choreo; + + /// if the event is a practice activity, the practice activity event + final PracticeActivityEvent? practiceActivity; + + /// if the event is a practice activity, the record model + final PracticeActivityRecordModel? recordModel; + + AnalyticsStream({ + required this.eventId, + required this.eventType, + required this.roomId, + this.originalSent, + this.tokensSent, + this.choreo, + this.practiceActivity, + this.recordModel, + }) { + assert( + (originalSent != null && tokensSent != null && choreo != null) || + (practiceActivity != null && recordModel != null), + "Either a message or a practice activity must be provided", + ); + + assert( + eventType == EventTypes.Message || + eventType == PangeaEventTypes.activityRecord, + ); + } +} diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index 243a49174..87552955f 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -105,7 +105,7 @@ class PangeaController { speechToText = SpeechToTextController(this); languageDetection = LanguageDetectionController(this); activityRecordController = PracticeActivityRecordController(this); - practiceGenerationController = PracticeGenerationController(); + practiceGenerationController = PracticeGenerationController(this); PAuthGaurd.pController = this; } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index 9b7f6b66e..b487dc726 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -1,18 +1,27 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'package:fluffychat/pangea/config/environment.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/pangea/network/requests.dart'; +import 'package:fluffychat/pangea/network/urls.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; import 'package:matrix/matrix.dart'; /// Represents an item in the completion cache. class _RequestCacheItem { - PracticeActivityRequest req; + MessageActivityRequest req; Future practiceActivityEvent; @@ -27,7 +36,10 @@ class PracticeGenerationController { static final Map _cache = {}; Timer? _cacheClearTimer; - PracticeGenerationController() { + late PangeaController _pangeaController; + + PracticeGenerationController(PangeaController pangeaController) { + _pangeaController = pangeaController; _initializeCacheClearing(); } @@ -64,8 +76,33 @@ class PracticeGenerationController { ); } + Future _fetch({ + required String accessToken, + required MessageActivityRequest requestModel, + }) async { + final Requests request = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: accessToken, + ); + final Response res = await request.post( + url: PApiUrls.messageActivityGeneration, + body: requestModel.toJson(), + ); + + if (res.statusCode == 200) { + final Map json = jsonDecode(utf8.decode(res.bodyBytes)); + + final response = PracticeActivityModel.fromJson(json); + + return response; + } else { + debugger(when: kDebugMode); + throw Exception('Failed to convert speech to text'); + } + } + Future getPracticeActivity( - PracticeActivityRequest req, + MessageActivityRequest req, PangeaMessageEvent event, ) async { final int cacheKey = req.hashCode; @@ -75,8 +112,13 @@ class PracticeGenerationController { } else { //TODO - send request to server/bot, either via API or via event of type pangeaActivityReq // for now, just make and send the event from the client + final PracticeActivityModel activity = await _fetch( + accessToken: _pangeaController.userController.accessToken, + requestModel: req, + ); + final Future eventFuture = - _sendAndPackageEvent(dummyModel(event), event); + _sendAndPackageEvent(activity, event); _cache[cacheKey] = _RequestCacheItem(req: req, practiceActivityEvent: eventFuture); @@ -85,7 +127,7 @@ class PracticeGenerationController { } } - PracticeActivityModel dummyModel(PangeaMessageEvent event) => + PracticeActivityModel _dummyModel(PangeaMessageEvent event) => PracticeActivityModel( tgtConstructs: [ ConstructIdentifier(lemma: "be", type: ConstructTypeEnum.vocab), @@ -97,6 +139,7 @@ class PracticeGenerationController { question: "What is a synonym for 'happy'?", choices: ["sad", "angry", "joyful", "tired"], answer: "joyful", + spanDisplayDetails: null, ), ); } diff --git a/lib/pangea/controllers/subscription_controller.dart b/lib/pangea/controllers/subscription_controller.dart index d5a04a82d..d90687ae1 100644 --- a/lib/pangea/controllers/subscription_controller.dart +++ b/lib/pangea/controllers/subscription_controller.dart @@ -106,7 +106,7 @@ class SubscriptionController extends BaseController { } } } - setState(); + setState(null); } catch (e, s) { debugPrint("Failed to initialize subscription controller"); ErrorHandler.logError(e: e, s: s); @@ -140,7 +140,7 @@ class SubscriptionController extends BaseController { PLocalKey.beganWebPayment, true, ); - setState(); + setState(null); launchUrlString( paymentLink, webOnlyWindowName: "_self", @@ -224,7 +224,7 @@ class SubscriptionController extends BaseController { return; } await subscription!.setCustomerInfo(); - setState(); + setState(null); } CanSendStatus get canSendStatus => isSubscribed diff --git a/lib/pangea/controllers/word_net_controller.dart b/lib/pangea/controllers/word_net_controller.dart index 81affd726..51f0c04ee 100644 --- a/lib/pangea/controllers/word_net_controller.dart +++ b/lib/pangea/controllers/word_net_controller.dart @@ -77,7 +77,7 @@ class WordController extends BaseController { if (local == null) { if (_wordData.length > 100) _wordData.clear(); _wordData.add(w); - setState(); + setState(null); } } } diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index 11c96e10e..780b8f9b7 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -1,15 +1,14 @@ -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix.dart'; enum MessageMode { - translation, - definition, - speechToText, + practiceActivity, textToSpeech, - practiceActivity + definition, + translation, + speechToText, } extension MessageModeExtension on MessageMode { @@ -80,28 +79,37 @@ extension MessageModeExtension on MessageMode { } } - Color? iconColor( - PangeaMessageEvent event, - MessageMode? currentMode, + bool isUnlocked( + int index, + int numActivitiesCompleted, + ) => + numActivitiesCompleted >= index; + + Color iconButtonColor( BuildContext context, + int index, + MessageMode currentMode, + int numActivitiesCompleted, ) { - final bool isPracticeActivity = this == MessageMode.practiceActivity; - final bool practicing = currentMode == MessageMode.practiceActivity; - final bool practiceEnabled = event.hasUncompletedActivity; - - // if this is the practice activity icon, and there's no practice activities available, - // and the current mode is not practice, return lower opacity color. - if (isPracticeActivity && !practicing && !practiceEnabled) { - return Theme.of(context).iconTheme.color?.withOpacity(0.5); + //locked + if (!isUnlocked(index, numActivitiesCompleted)) { + return barAndLockedButtonColor(context); } - // if this is not a practice activity icon, and practice activities are available, - // then return lower opacity color if the current mode is practice. - if (!isPracticeActivity && practicing && practiceEnabled) { - return Theme.of(context).iconTheme.color?.withOpacity(0.5); + //unlocked and active + if (this == currentMode) { + return Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary; } - // if this is the current mode, return primary color. - return currentMode == this ? Theme.of(context).colorScheme.primary : null; + //unlocked and inactive + return Theme.of(context).colorScheme.primaryContainer; + } + + static Color barAndLockedButtonColor(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark + ? Colors.grey[800]! + : Colors.grey[200]!; } } diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index 88c4c1acf..86fae48e3 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -349,6 +350,7 @@ class PangeaMessageEvent { _representations?.add( RepresentationEvent( timeline: timeline, + parentMessageEvent: _event, content: PangeaRepresentation( langCode: response.langCode, text: response.transcript.text, @@ -362,29 +364,54 @@ class PangeaMessageEvent { return response; } + PangeaMessageTokens? _tokensSafe(Map? content) { + try { + if (content == null) return null; + return PangeaMessageTokens.fromJson(content); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + s: s, + data: content, + m: "error parsing tokensSent", + ); + return null; + } + } + + ChoreoRecord? get _embeddedChoreo { + try { + if (_latestEdit.content[ModelKey.choreoRecord] == null) return null; + return ChoreoRecord.fromJson( + _latestEdit.content[ModelKey.choreoRecord] as Map, + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: _latestEdit.content, + m: "error parsing choreoRecord", + ); + return null; + } + } + List? _representations; List get representations { if (_representations != null) return _representations!; _representations = []; - if (_latestEdit.content[ModelKey.originalSent] != null) { try { final RepresentationEvent sent = RepresentationEvent( + parentMessageEvent: _event, content: PangeaRepresentation.fromJson( _latestEdit.content[ModelKey.originalSent] as Map, ), - tokens: _latestEdit.content[ModelKey.tokensSent] != null - ? PangeaMessageTokens.fromJson( - _latestEdit.content[ModelKey.tokensSent] - as Map, - ) - : null, - choreo: _latestEdit.content[ModelKey.choreoRecord] != null - ? ChoreoRecord.fromJson( - _latestEdit.content[ModelKey.choreoRecord] - as Map, - ) - : null, + tokens: _tokensSafe( + _latestEdit.content[ModelKey.tokensSent] as Map?, + ), + choreo: _embeddedChoreo, timeline: timeline, ); if (_latestEdit.content[ModelKey.choreoRecord] == null) { @@ -413,16 +440,15 @@ class PangeaMessageEvent { try { _representations!.add( RepresentationEvent( + parentMessageEvent: _event, content: PangeaRepresentation.fromJson( _latestEdit.content[ModelKey.originalWritten] as Map, ), - tokens: _latestEdit.content[ModelKey.tokensWritten] != null - ? PangeaMessageTokens.fromJson( - _latestEdit.content[ModelKey.tokensWritten] - as Map, - ) - : null, + tokens: _tokensSafe( + _latestEdit.content[ModelKey.tokensWritten] + as Map?, + ), timeline: timeline, ), ); @@ -442,7 +468,11 @@ class PangeaMessageEvent { PangeaEventTypes.representation, ) .map( - (e) => RepresentationEvent(event: e, timeline: timeline), + (e) => RepresentationEvent( + event: e, + parentMessageEvent: _event, + timeline: timeline, + ), ) .sorted( (a, b) { @@ -487,36 +517,20 @@ class PangeaMessageEvent { final PangeaRepresentation? basis = (originalWritten ?? originalSent)?.content; - final PangeaRepresentation? pangeaRep = - await MatrixState.pangeaController.messageData.getPangeaRepresentation( - text: basis?.text ?? _latestEdit.body, - source: basis?.langCode, - target: langCode, - room: _latestEdit.room, - ); - if (pangeaRep == null) return null; + // clear representations cache so the new representation event can be added + // when next requested + _representations = null; - MatrixState.pangeaController.messageData - .sendRepresentationMatrixEvent( - representation: pangeaRep, - messageEventId: _latestEdit.eventId, - room: _latestEdit.room, - target: langCode, - ) - .then( - (value) { - representations.add( - RepresentationEvent( - event: value, - timeline: timeline, - ), - ); - }, - ).onError( - (error, stackTrace) => ErrorHandler.logError(e: error, s: stackTrace), + return MatrixState.pangeaController.messageData.getPangeaRepresentation( + req: FullTextTranslationRequestModel( + text: basis?.text ?? _latestEdit.body, + srcLang: basis?.langCode, + tgtLang: langCode, + userL2: l2Code ?? LanguageKeys.unknownLanguage, + userL1: l1Code ?? LanguageKeys.unknownLanguage, + ), + messageEvent: _event, ); - - return pangeaRep; } RepresentationEvent? get originalSent => representations @@ -556,7 +570,9 @@ class PangeaMessageEvent { // this is just showActivityIcon now but will include // logic for showing - bool get showMessageButtons => hasUncompletedActivity; + // NOTE: turning this off for now + bool get showMessageButtons => false; + // bool get showMessageButtons => hasUncompletedActivity; /// Returns a boolean value indicating whether to show an activity icon for this message event. /// @@ -572,9 +588,16 @@ class PangeaMessageEvent { return practiceActivities.any((activity) => !(activity.isComplete)); } + int get numberOfActivitiesCompleted { + return practiceActivities.where((activity) => activity.isComplete).length; + } + String? get l2Code => MatrixState.pangeaController.languageController.activeL2Code(); + String? get l1Code => + MatrixState.pangeaController.languageController.userL1?.langCode; + String get messageDisplayLangCode { final bool immersionMode = MatrixState .pangeaController.permissionsController @@ -587,6 +610,14 @@ class PangeaMessageEvent { return langCode ?? LanguageKeys.unknownLanguage; } + /// Gets the message display text for the current language code. + /// If the message display text is not available for the current language code, + /// it returns the message body. + String get messageDisplayText { + final String? text = representationByLanguage(messageDisplayLangCode)?.text; + return text ?? body; + } + List? errorSteps(String lemma) { final RepresentationEvent? repEvent = originalSent ?? originalWritten; if (repEvent?.choreo == null) return null; @@ -636,6 +667,7 @@ class PangeaMessageEvent { String langCode, { bool debug = false, }) { + // @wcjord - disabled try catch for testing try { debugger(when: debug); final List activities = []; diff --git a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart index e6e45b756..970d88da8 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart @@ -17,7 +17,6 @@ import '../constants/pangea_event_types.dart'; import '../models/choreo_record.dart'; import '../models/representation_content_model.dart'; import '../utils/error_handler.dart'; -import 'pangea_tokens_event.dart'; class RepresentationEvent { Event? _event; @@ -25,9 +24,11 @@ class RepresentationEvent { PangeaMessageTokens? _tokens; ChoreoRecord? _choreo; Timeline timeline; + Event parentMessageEvent; RepresentationEvent({ required this.timeline, + required this.parentMessageEvent, Event? event, PangeaRepresentation? content, PangeaMessageTokens? tokens, @@ -102,23 +103,23 @@ class RepresentationEvent { return _tokens?.tokens; } - Future?> tokensGlobal(BuildContext context) async { + Future> tokensGlobal(BuildContext context) async { if (tokens != null) return tokens!; if (_event == null) { - // debugger(when: kDebugMode); - // ErrorHandler.logError( - // m: '_event and _tokens both null', - // s: StackTrace.current, - // ); - return null; + ErrorHandler.logError( + m: 'representation with no _event and no tokens got tokens directly. This means an original_sent with no tokens. This should not happen in messages sent after September 25', + s: StackTrace.current, + data: { + 'content': content.toJson(), + 'event': _event?.toJson(), + }, + ); } - - final Event? tokensEvent = - await MatrixState.pangeaController.messageData.getTokenEvent( - context: context, - repEventId: _event!.eventId, - room: _event!.room, + final List res = + await MatrixState.pangeaController.messageData.getTokens( + repEventId: _event?.eventId, + room: _event?.room ?? parentMessageEvent.room, // Jordan - for just tokens, it's not clear which languages to pass req: TokensRequestModel( fullText: text, @@ -129,11 +130,7 @@ class RepresentationEvent { ), ); - if (tokensEvent == null) return null; - - _tokens = TokensEvent(event: tokensEvent).tokens; - - return _tokens?.tokens; + return res; } ChoreoRecord? get choreo { diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index 3d1185d05..6616a8c06 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -35,8 +35,14 @@ class PracticeActivityEvent { } PracticeActivityModel get practiceActivity { - _content ??= event.getPangeaContent(); - return _content!; + try { + _content ??= event.getPangeaContent(); + return _content!; + } catch (e, s) { + final contentMap = event.content; + debugger(when: kDebugMode); + rethrow; + } } /// All completion records assosiated with this activity diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 661fa9d0b..723f89423 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -6,53 +6,76 @@ import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; /// the process of filtering / sorting / displaying the events. /// Takes a construct type and a list of events class ConstructListModel { - final ConstructTypeEnum type; + final ConstructTypeEnum? type; final List _uses; + List? _constructList; + List? _typedConstructs; + + /// A map of lemmas to ConstructUses, each of which contains a lemma + /// key = lemmma + constructType.string, value = ConstructUses + Map? _constructMap; ConstructListModel({ required this.type, - uses, - }) : _uses = uses ?? []; - - List? _constructs; - List? _typedConstructs; + required List uses, + }) : _uses = uses; List get uses => _uses.where((use) => use.constructType == type).toList(); /// All unique lemmas used in the construct events - List get lemmas => constructs.map((e) => e.lemma).toSet().toList(); + List get lemmas => constructList.map((e) => e.lemma).toSet().toList(); - /// A list of ConstructUses, each of which contains a lemma and - /// a list of uses, sorted by the number of uses - List get constructs { - // the list of uses doesn't change so we don't have to re-calculate this - if (_constructs != null) return _constructs!; + /// A map of lemmas to ConstructUses, each of which contains a lemma + /// key = lemmma + constructType.string, value = ConstructUses + void _buildConstructMap() { final Map> lemmaToUses = {}; for (final use in uses) { if (use.lemma == null) continue; - lemmaToUses[use.lemma!] ??= []; - lemmaToUses[use.lemma!]!.add(use); + lemmaToUses[use.lemma! + use.constructType.string] ??= []; + lemmaToUses[use.lemma! + use.constructType.string]!.add(use); } - final constructUses = lemmaToUses.entries - .map( - (entry) => ConstructUses( - lemma: entry.key, - uses: entry.value, - constructType: type, - ), - ) - .toList(); + _constructMap = lemmaToUses.map( + (key, value) => MapEntry( + key + value.first.constructType.string, + ConstructUses( + uses: value, + constructType: value.first.constructType, + lemma: value.first.lemma!, + ), + ), + ); + } - constructUses.sort((a, b) { + ConstructUses? getConstructUses(String lemma, ConstructTypeEnum type) { + if (_constructMap == null) _buildConstructMap(); + return _constructMap![lemma + type.string]; + } + + /// A list of ConstructUses, each of which contains a lemma and + /// a list of uses, sorted by the number of uses + List get constructList { + // the list of uses doesn't change so we don't have to re-calculate this + if (_constructList != null) return _constructList!; + + if (_constructMap == null) _buildConstructMap(); + + _constructList = _constructMap!.values.toList(); + + _constructList!.sort((a, b) { final comp = b.uses.length.compareTo(a.uses.length); if (comp != 0) return comp; return a.lemma.compareTo(b.lemma); }); - _constructs = constructUses; - return constructUses; + return _constructList!; + } + + get maxXPPerLemma { + return type != null + ? type!.maxXPPerLemma + : ConstructTypeEnum.vocab.maxXPPerLemma; } /// A list of ConstructUseTypeUses, each of which @@ -60,7 +83,7 @@ class ConstructListModel { List get typedConstructs { if (_typedConstructs != null) return _typedConstructs!; final List typedConstructs = []; - for (final construct in constructs) { + for (final construct in constructList) { final typeToUses = >{}; for (final use in construct.uses) { typeToUses[use.useType] ??= []; @@ -70,7 +93,7 @@ class ConstructListModel { typedConstructs.add( ConstructUseTypeUses( lemma: construct.lemma, - constructType: type, + constructType: typeEntry.value.first.constructType, useType: typeEntry.key, uses: typeEntry.value, ), @@ -125,6 +148,16 @@ class ConstructUses { (total, use) => total + use.useType.pointValue, ); } + + DateTime? _lastUsed; + DateTime? get lastUsed { + if (_lastUsed != null) return _lastUsed; + final lastUse = uses.fold(null, (DateTime? last, use) { + if (last == null) return use.timeStamp; + return use.timeStamp.isAfter(last) ? use.timeStamp : last; + }); + return _lastUsed = lastUse; + } } /// One lemma, a use type, and a list of uses diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index fd9710a80..a1495a276 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -76,7 +76,7 @@ class OneConstructUse { String? lemma; String? form; List categories; - ConstructTypeEnum? constructType; + ConstructTypeEnum constructType; ConstructUseTypeEnum useType; String? id; ConstructUseMetaData metadata; @@ -96,6 +96,11 @@ class OneConstructUse { DateTime get timeStamp => metadata.timeStamp; factory OneConstructUse.fromJson(Map json) { + final constructType = json['constructType'] != null + ? ConstructTypeUtil.fromString(json['constructType']) + : null; + debugger(when: kDebugMode && constructType == null); + return OneConstructUse( useType: ConstructUseTypeEnum.values .firstWhereOrNull((e) => e.string == json['useType']) ?? @@ -105,9 +110,7 @@ class OneConstructUse { categories: json['categories'] != null ? List.from(json['categories']) : [], - constructType: json['constructType'] != null - ? ConstructTypeUtil.fromString(json['constructType']) - : null, + constructType: constructType ?? ConstructTypeEnum.vocab, id: json['id'], metadata: ConstructUseMetaData( eventId: json['msgId'], @@ -117,7 +120,7 @@ class OneConstructUse { ); } - Map toJson([bool condensed = false]) { + Map toJson() { final Map data = { 'useType': useType.string, 'chatId': metadata.roomId, @@ -125,10 +128,10 @@ class OneConstructUse { 'form': form, 'msgId': metadata.eventId, }; - if (!condensed && lemma != null) data['lemma'] = lemma!; - if (!condensed && constructType != null) { - data['constructType'] = constructType!.string; - } + + data['lemma'] = lemma!; + data['constructType'] = constructType.string; + if (id != null) data['id'] = id; data['categories'] = categories; return data; diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index fe95dfc09..ace5a738e 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -145,6 +145,7 @@ class ChoreoRecord { lemma: name, form: name, constructType: ConstructTypeEnum.grammar, + // @ggurdin what is this used for? id: "${metadata.eventId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}", metadata: metadata, ), diff --git a/lib/pangea/models/igc_text_data_model.dart b/lib/pangea/models/igc_text_data_model.dart index 014b39524..be64491c4 100644 --- a/lib/pangea/models/igc_text_data_model.dart +++ b/lib/pangea/models/igc_text_data_model.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/controllers/language_detection_controller.dart import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/span_card_model.dart'; +import 'package:fluffychat/pangea/models/span_data.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -126,14 +127,32 @@ class IGCTextData { return; } - final String replacement = pangeaMatch.match.choices![choiceIndex].value; + final SpanChoice replacement = pangeaMatch.match.choices![choiceIndex]; originalInput = originalInput.replaceRange( pangeaMatch.match.offset, pangeaMatch.match.offset + pangeaMatch.match.length, - replacement, + replacement.value, ); + // replace the tokens that are part of the match + // with the tokens in the replacement + // start is inclusive + final startIndex = tokenIndexByOffset(pangeaMatch.match.offset); + // end is exclusive, hence the +1 + final endIndex = tokenIndexByOffset( + pangeaMatch.match.offset + pangeaMatch.match.length, + ) + + 1; + // replace the tokens in the list + tokens.replaceRange(startIndex, endIndex, replacement.tokens); + + //for all tokens after the replacement, update their offsets + for (int i = endIndex; i < tokens.length; i++) { + final PangeaToken token = tokens[i]; + token.text.offset += replacement.value.length - pangeaMatch.match.length; + } + //update offsets in existing matches to reflect the change //Question - remove matches that overlap with the accepted one? // see case of "quiero ver un fix" @@ -142,18 +161,10 @@ class IGCTextData { for (final match in matches) { match.match.fullText = originalInput; if (match.match.offset > pangeaMatch.match.offset) { - match.match.offset += replacement.length - pangeaMatch.match.length; + match.match.offset += + replacement.value.length - pangeaMatch.match.length; } } - //quiero ver un fix - //match offset zero and length of full text or 16 - //fix is repplaced by arreglo and now the length needs to be 20 - //if the accepted span is within another span, then the length of that span needs - //needs to be increased by the difference between the new and old length - //if the two spans are overlapping, what happens? - //------ - // ----- -> --- - //if there is any overlap, maybe igc needs to run again? } void removeMatchByOffset(int offset) { @@ -163,9 +174,8 @@ class IGCTextData { } } - int tokenIndexByOffset(cursorOffset) => tokens.indexWhere( - (token) => - token.text.offset <= cursorOffset && cursorOffset <= token.end, + int tokenIndexByOffset(int cursorOffset) => tokens.indexWhere( + (token) => token.start <= cursorOffset && cursorOffset <= token.end, ); List matchIndicesByOffset(int offset) { diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index 089521e1a..e0697d859 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -1,5 +1,9 @@ import 'dart:developer'; +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; import '../constants/model_keys.dart'; @@ -24,6 +28,43 @@ class PangeaToken { required this.morph, }); + static String reconstructText( + List tokens, [ + int startTokenIndex = 0, + int endTokenIndex = -1, + ]) { + if (endTokenIndex == -1) { + endTokenIndex = tokens.length - 1; + } + + final List subset = + tokens.whereIndexed((int index, PangeaToken token) { + return index >= startTokenIndex && index <= endTokenIndex; + }).toList(); + + if (subset.isEmpty) { + debugger(when: kDebugMode); + return ''; + } + + if (subset.length == 1) { + return subset.first.text.content; + } + + String reconstruction = subset.first.text.content; + for (int i = 1; i < subset.length - 1; i++) { + int whitespace = subset[i].text.offset - + (subset[i - 1].text.offset + subset[i - 1].text.length); + if (whitespace < 0) { + debugger(when: kDebugMode); + whitespace = 0; + } + reconstruction += ' ' * whitespace + subset[i].text.content; + } + + return reconstruction; + } + static Lemma _getLemmas(String text, dynamic json) { if (json != null) { // July 24, 2024 - we're changing from a list to a single lemma and this is for backwards compatibility @@ -67,7 +108,45 @@ class PangeaToken { 'morph': morph, }; + /// alias for the offset + int get start => text.offset; + + /// alias for the end of the token ie offset + length int get end => text.offset + text.length; + + /// create an empty tokenWithXP object + TokenWithXP get emptyTokenWithXP { + final List constructs = []; + + constructs.add( + ConstructWithXP( + id: ConstructIdentifier( + lemma: lemma.text, + type: ConstructTypeEnum.vocab, + ), + xp: 0, + lastUsed: null, + ), + ); + + for (final morph in morph.entries) { + constructs.add( + ConstructWithXP( + id: ConstructIdentifier( + lemma: morph.key, + type: ConstructTypeEnum.morph, + ), + xp: 0, + lastUsed: null, + ), + ); + } + + return TokenWithXP( + token: this, + constructs: constructs, + ); + } } class PangeaTokenText { @@ -96,4 +175,18 @@ class PangeaTokenText { Map toJson() => {_offsetKey: offset, _contentKey: content, _lengthKey: length}; + + //override equals and hashcode + @override + bool operator ==(Object other) { + if (other is PangeaTokenText) { + return other.offset == offset && + other.content == content && + other.length == length; + } + return false; + } + + @override + int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode; } diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart new file mode 100644 index 000000000..5639057db --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -0,0 +1,150 @@ +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; + +class ConstructWithXP { + final ConstructIdentifier id; + int xp; + final DateTime? lastUsed; + + ConstructWithXP({ + required this.id, + required this.xp, + required this.lastUsed, + }); + + factory ConstructWithXP.fromJson(Map json) { + return ConstructWithXP( + id: ConstructIdentifier.fromJson( + json['construct_id'] as Map, + ), + xp: json['xp'] as int, + lastUsed: json['last_used'] != null + ? DateTime.parse(json['last_used'] as String) + : null, + ); + } + + Map toJson() { + return { + 'construct_id': id.toJson(), + 'xp': xp, + 'last_used': lastUsed?.toIso8601String(), + }; + } +} + +class TokenWithXP { + final PangeaToken token; + final List constructs; + + DateTime? get lastUsed { + return constructs.fold( + null, + (previousValue, element) { + if (previousValue == null) return element.lastUsed; + if (element.lastUsed == null) return previousValue; + return element.lastUsed!.isAfter(previousValue) + ? element.lastUsed + : previousValue; + }, + ); + } + + int get xp { + return constructs.fold( + 0, + (previousValue, element) => previousValue + element.xp, + ); + } + + TokenWithXP({ + required this.token, + required this.constructs, + }); + + factory TokenWithXP.fromJson(Map json) { + return TokenWithXP( + token: PangeaToken.fromJson(json['token'] as Map), + constructs: (json['constructs'] as List) + .map((e) => ConstructWithXP.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'token': token.toJson(), + 'constructs_with_xp': constructs.map((e) => e.toJson()).toList(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is TokenWithXP && + other.token.text == token.text && + other.lastUsed == lastUsed; + } + + @override + int get hashCode { + return token.text.hashCode ^ lastUsed.hashCode; + } +} + +class MessageActivityRequest { + final String userL1; + final String userL2; + + final String messageText; + final List tokensWithXP; + + final String messageId; + + MessageActivityRequest({ + required this.userL1, + required this.userL2, + required this.messageText, + required this.tokensWithXP, + required this.messageId, + }); + + factory MessageActivityRequest.fromJson(Map json) { + return MessageActivityRequest( + userL1: json['user_l1'] as String, + userL2: json['user_l2'] as String, + messageText: json['message_text'] as String, + tokensWithXP: (json['tokens_with_xp'] as List) + .map((e) => TokenWithXP.fromJson(e as Map)) + .toList(), + messageId: json['message_id'] as String, + ); + } + + Map toJson() { + return { + 'user_l1': userL1, + 'user_l2': userL2, + 'message_text': messageText, + 'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), + 'message_id': messageId, + }; + } + + // equals accounts for message_id and last_used of each token + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is MessageActivityRequest && + other.messageId == messageId && + const ListEquality().equals(other.tokensWithXP, tokensWithXP); + } + + @override + int get hashCode { + return messageId.hashCode ^ const ListEquality().hash(tokensWithXP); + } +} diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart index 18302bd43..28c18d7c0 100644 --- a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -1,5 +1,8 @@ +import 'dart:developer'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; class MultipleChoice { @@ -12,10 +15,18 @@ class MultipleChoice { required this.question, required this.choices, required this.answer, - this.spanDisplayDetails, + required this.spanDisplayDetails, }); - bool isCorrect(int index) => index == correctAnswerIndex; + /// we've had some bugs where the index is not expected + /// so we're going to check if the index or the value is correct + /// and if not, we'll investigate + bool isCorrect(String value, int index) { + if (value != choices[index]) { + debugger(when: kDebugMode); + } + return value == answer || index == correctAnswerIndex; + } bool get isValidQuestion => choices.contains(answer); @@ -27,13 +38,15 @@ class MultipleChoice { index == correctAnswerIndex ? AppConfig.success : AppConfig.warning; factory MultipleChoice.fromJson(Map json) { + final spanDisplay = json['span_display_details'] != null && + json['span_display_details'] is Map + ? RelevantSpanDisplayDetails.fromJson(json['span_display_details']) + : null; return MultipleChoice( question: json['question'] as String, choices: (json['choices'] as List).map((e) => e as String).toList(), answer: json['answer'] ?? json['correct_answer'] as String, - spanDisplayDetails: json['span_display_details'] != null - ? RelevantSpanDisplayDetails.fromJson(json['span_display_details']) - : null, + spanDisplayDetails: spanDisplay, ); } @@ -42,7 +55,7 @@ class MultipleChoice { 'question': question, 'choices': choices, 'answer': answer, - 'span_display_details': spanDisplayDetails, + 'span_display_details': spanDisplayDetails?.toJson(), }; } } diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index 645d550e5..0511d8055 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -35,6 +35,21 @@ class ConstructIdentifier { 'type': type.string, }; } + + // override operator == and hashCode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ConstructIdentifier && + other.lemma == lemma && + other.type == type; + } + + @override + int get hashCode { + return lemma.hashCode ^ type.hashCode; + } } class CandidateMessage { @@ -269,6 +284,8 @@ class PracticeActivityModel { ); } + RelevantSpanDisplayDetails? get relevantSpanDisplayDetails => + multipleChoice?.spanDisplayDetails; Map toJson() { return { 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), @@ -282,20 +299,32 @@ class PracticeActivityModel { }; } - RelevantSpanDisplayDetails? getRelevantSpanDisplayDetails() { - switch (activityType) { - case ActivityTypeEnum.multipleChoice: - return multipleChoice?.spanDisplayDetails; - case ActivityTypeEnum.listening: - return null; - case ActivityTypeEnum.speaking: - return null; - case ActivityTypeEnum.freeResponse: - return null; - default: - debugger(when: kDebugMode); - return null; - } + // override operator == and hashCode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PracticeActivityModel && + const ListEquality().equals(other.tgtConstructs, tgtConstructs) && + other.langCode == langCode && + other.msgId == msgId && + other.activityType == activityType && + other.multipleChoice == multipleChoice && + other.listening == listening && + other.speaking == speaking && + other.freeResponse == freeResponse; + } + + @override + int get hashCode { + return const ListEquality().hash(tgtConstructs) ^ + langCode.hashCode ^ + msgId.hashCode ^ + activityType.hashCode ^ + multipleChoice.hashCode ^ + listening.hashCode ^ + speaking.hashCode ^ + freeResponse.hashCode; } } @@ -332,7 +361,23 @@ class RelevantSpanDisplayDetails { return { 'offset': offset, 'length': length, - 'display_instructions': displayInstructions, + 'display_instructions': displayInstructions.string, }; } + + // override operator == and hashCode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is RelevantSpanDisplayDetails && + other.offset == offset && + other.length == length && + other.displayInstructions == displayInstructions; + } + + @override + int get hashCode { + return offset.hashCode ^ length.hashCode ^ displayInstructions.hashCode; + } } diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart index 34f73e735..90c30a17a 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -63,6 +63,10 @@ class PracticeActivityRecordModel { : ConstructUseTypeEnum.incPA) : ConstructUseTypeEnum.unk; + bool hasTextResponse(String text) { + return responses.any((element) => element.text == text); + } + void addResponse({ String? text, Uint8List? audioBytes, @@ -80,7 +84,7 @@ class PracticeActivityRecordModel { ), ); } catch (e) { - debugger(); + debugger(when: kDebugMode); } } diff --git a/lib/pangea/models/tokens_event_content_model.dart b/lib/pangea/models/tokens_event_content_model.dart index f2a7db7a6..c4cdbda9c 100644 --- a/lib/pangea/models/tokens_event_content_model.dart +++ b/lib/pangea/models/tokens_event_content_model.dart @@ -14,8 +14,17 @@ class PangeaMessageTokens { }); factory PangeaMessageTokens.fromJson(Map json) { + // "tokens" was accidentally used as the key in the first implementation + // _tokensKey is the correct key + final something = json[_tokensKey] ?? json["tokens"]; + + final Iterable tokensIterable = something is Iterable + ? something + : something is String + ? jsonDecode(json[_tokensKey]) + : null; return PangeaMessageTokens( - tokens: (jsonDecode(json[_tokensKey] ?? "[]") as Iterable) + tokens: tokensIterable .map((e) => PangeaToken.fromJson(e)) .toList() .cast(), diff --git a/lib/pangea/network/urls.dart b/lib/pangea/network/urls.dart index ff0404947..9a8421d83 100644 --- a/lib/pangea/network/urls.dart +++ b/lib/pangea/network/urls.dart @@ -52,6 +52,9 @@ class PApiUrls { static String textToSpeech = "${Environment.choreoApi}/text_to_speech"; static String speechToText = "${Environment.choreoApi}/speech_to_text"; + static String messageActivityGeneration = + "${Environment.choreoApi}/practice/message"; + ///-------------------------------- revenue cat -------------------------- static String rcApiV1 = "https://api.revenuecat.com/v1"; static String rcApiV2 = diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 497e60cc0..b859b991b 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -198,7 +198,7 @@ class ConstructListViewState extends State { setState(() => fetchingUses = true); try { - final List uses = constructs?.constructs + final List uses = constructs?.constructList .firstWhereOrNull( (element) => element.lemma == currentLemma, ) @@ -276,7 +276,7 @@ class ConstructListViewState extends State { ); } - if (constructs?.constructs.isEmpty ?? true) { + if (constructs?.constructList.isEmpty ?? true) { return Expanded( child: Center(child: Text(L10n.of(context)!.noDataFound)), ); @@ -284,17 +284,17 @@ class ConstructListViewState extends State { return Expanded( child: ListView.builder( - itemCount: constructs!.constructs.length, + itemCount: constructs!.constructList.length, itemBuilder: (context, index) { return ListTile( title: Text( - constructs!.constructs[index].lemma, + constructs!.constructList[index].lemma, ), subtitle: Text( - '${L10n.of(context)!.total} ${constructs!.constructs[index].uses.length}', + '${L10n.of(context)!.total} ${constructs!.constructList[index].uses.length}', ), onTap: () async { - final String lemma = constructs!.constructs[index].lemma; + final String lemma = constructs!.constructList[index].lemma; setCurrentLemma(lemma); fetchUses().then((_) => showConstructMessagesDialog()); }, @@ -320,7 +320,8 @@ class ConstructMessagesDialog extends StatelessWidget { final msgEventMatches = controller.getMessageEventMatches(); - final currentConstruct = controller.constructs!.constructs.firstWhereOrNull( + final currentConstruct = + controller.constructs!.constructList.firstWhereOrNull( (construct) => construct.lemma == controller.currentLemma, ); final noData = currentConstruct == null || diff --git a/lib/pangea/repo/contextualized_translation_repo.dart b/lib/pangea/repo/contextualized_translation_repo.dart index c2ffe1853..d5cd8f9e0 100644 --- a/lib/pangea/repo/contextualized_translation_repo.dart +++ b/lib/pangea/repo/contextualized_translation_repo.dart @@ -1,17 +1,16 @@ import 'dart:convert'; -import 'package:http/http.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:http/http.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + import '../config/environment.dart'; import '../models/pangea_token_model.dart'; import '../network/requests.dart'; import '../network/urls.dart'; class ContextualizationTranslationRepo { - //Question for Jordan - is this for an individual token or could it be a span? static Future translate({ required String accessToken, required ContextualTranslationRequestModel request, diff --git a/lib/pangea/repo/full_text_translation_repo.dart b/lib/pangea/repo/full_text_translation_repo.dart index 704bb9d63..be15e7855 100644 --- a/lib/pangea/repo/full_text_translation_repo.dart +++ b/lib/pangea/repo/full_text_translation_repo.dart @@ -1,5 +1,6 @@ //Question for Jordan - is this for an individual token or could it be a span? +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart'; @@ -10,10 +11,58 @@ import '../network/requests.dart'; import '../network/urls.dart'; class FullTextTranslationRepo { + static final Map _cache = {}; + static Timer? _cacheTimer; + + // start a timer to clear the cache + static void startCacheTimer() { + _cacheTimer = Timer.periodic(const Duration(minutes: 3), (timer) { + clearCache(); + }); + } + + // stop the cache time (optional) + static void stopCacheTimer() { + _cacheTimer?.cancel(); + } + + // method to clear the cache + static void clearCache() { + _cache.clear(); + } + + static String _generateCacheKey({ + required String text, + required String srcLang, + required String tgtLang, + required int offset, + required int length, + bool? deepL, + }) { + return '${text.hashCode}-$srcLang-$tgtLang-$deepL-$offset-$length'; + } + static Future translate({ required String accessToken, required FullTextTranslationRequestModel request, }) async { + // start cache timer when the first API call is made + startCacheTimer(); + + final cacheKey = _generateCacheKey( + text: request.text, + srcLang: request.srcLang ?? '', + tgtLang: request.tgtLang, + offset: request.offset ?? 0, + length: request.length ?? 0, + deepL: request.deepL, + ); + + // check cache first + if (_cache.containsKey(cacheKey)) { + return _cache[cacheKey]!; + } + final Requests req = Requests( choreoApiKey: Environment.choreoApiKey, accessToken: accessToken, @@ -24,9 +73,14 @@ class FullTextTranslationRepo { body: request.toJson(), ); - return FullTextTranslationResponseModel.fromJson( + final responseModel = FullTextTranslationResponseModel.fromJson( jsonDecode(utf8.decode(res.bodyBytes)), ); + + // store response in cache + _cache[cacheKey] = responseModel; + + return responseModel; } } @@ -63,6 +117,33 @@ class FullTextTranslationRequestModel { ModelKey.offset: offset, ModelKey.length: length, }; + + // override equals and hashcode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is FullTextTranslationRequestModel && + other.text == text && + other.srcLang == srcLang && + other.tgtLang == tgtLang && + other.userL2 == userL2 && + other.userL1 == userL1 && + other.deepL == deepL && + other.offset == offset && + other.length == length; + } + + @override + int get hashCode => + text.hashCode ^ + srcLang.hashCode ^ + tgtLang.hashCode ^ + userL2.hashCode ^ + userL1.hashCode ^ + deepL.hashCode ^ + offset.hashCode ^ + length.hashCode; } class FullTextTranslationResponseModel { diff --git a/lib/pangea/repo/tokens_repo.dart b/lib/pangea/repo/tokens_repo.dart index 8d47e1a2c..de539b453 100644 --- a/lib/pangea/repo/tokens_repo.dart +++ b/lib/pangea/repo/tokens_repo.dart @@ -58,6 +58,20 @@ class TokensRequestModel { ModelKey.userL1: userL1, ModelKey.userL2: userL2, }; + + // override equals and hashcode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is TokensRequestModel && + other.fullText == fullText && + other.userL1 == userL1 && + other.userL2 == userL2; + } + + @override + int get hashCode => fullText.hashCode ^ userL1.hashCode ^ userL2.hashCode; } class TokensResponseModel { diff --git a/lib/pangea/utils/logout.dart b/lib/pangea/utils/logout.dart index 6c57754ef..e2bdce074 100644 --- a/lib/pangea/utils/logout.dart +++ b/lib/pangea/utils/logout.dart @@ -20,7 +20,8 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async { final matrix = Matrix.of(context); // before wiping out locally cached construct data, save it to the server - await MatrixState.pangeaController.myAnalytics.updateAnalytics(); + await MatrixState.pangeaController.myAnalytics + .sendLocalAnalyticsToAnalyticsRoom(); await showFutureLoadingDialog( context: context, diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 5c1f8e67b..9a8fa0f34 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -45,7 +45,7 @@ class MessageAudioCardState extends State { audioFile = await widget.messageEvent.getMatrixAudioFile(langCode, context); if (mounted) setState(() => _isLoading = false); - } catch (e, _) { + } catch (e, s) { debugPrint(StackTrace.current.toString()); if (!mounted) return; setState(() => _isLoading = false); @@ -56,7 +56,7 @@ class MessageAudioCardState extends State { ); ErrorHandler.logError( e: Exception(), - s: StackTrace.current, + s: s, m: 'something wrong getting audio in MessageAudioCardState', data: { 'widget.messageEvent.messageDisplayLangCode': diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 758a7edec..72d87df4a 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; @@ -5,44 +7,58 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; class MessageSelectionOverlay extends StatefulWidget { - final ChatController controller; - final Event event; - final Event? nextEvent; - final Event? prevEvent; - final PangeaMessageEvent pangeaMessageEvent; - final MessageMode? initialMode; - final MessageTextSelection textSelection; + final ChatController chatController; + late final Event _event; + late final Event? _nextEvent; + late final Event? _prevEvent; + late final PangeaMessageEvent _pangeaMessageEvent; - const MessageSelectionOverlay({ - required this.controller, - required this.event, - required this.pangeaMessageEvent, - required this.textSelection, - this.initialMode, - this.nextEvent, - this.prevEvent, + MessageSelectionOverlay({ + required this.chatController, + required Event event, + required PangeaMessageEvent pangeaMessageEvent, + required Event? nextEvent, + required Event? prevEvent, super.key, - }); + }) { + _pangeaMessageEvent = pangeaMessageEvent; + _nextEvent = nextEvent; + _prevEvent = prevEvent; + _event = event; + } @override - MessageSelectionOverlayState createState() => MessageSelectionOverlayState(); + MessageOverlayController createState() => MessageOverlayController(); } -class MessageSelectionOverlayState extends State +class MessageOverlayController extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; Animation? _overlayPositionAnimation; + MessageMode toolbarMode = MessageMode.translation; + PangeaTokenText? _selectedSpan; + + /// The number of activities that need to be completed before the toolbar is unlocked + int needed = 3; + + /// Whether the user has completed the activities needed to unlock the toolbar + /// within this overlay 'session'. if they click out and come back in then + /// we can give them some more activities to complete + bool finishedActivitiesThisSession = false; + @override void initState() { super.initState(); @@ -50,8 +66,114 @@ class MessageSelectionOverlayState extends State vsync: this, duration: FluffyThemes.animationDuration, ); + + setInitialToolbarMode(); } + int get activitiesLeftToComplete => + needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; + + /// In some cases, we need to exit the practice flow and let the user + /// interact with the toolbar without completing activities + void exitPracticeFlow() { + needed = 0; + setInitialToolbarMode(); + setState(() {}); + } + + Future setInitialToolbarMode() async { + if (activitiesLeftToComplete > 0) { + toolbarMode = MessageMode.practiceActivity; + return; + } + + if (widget._pangeaMessageEvent.isAudioMessage) { + toolbarMode = MessageMode.speechToText; + return; + } + + if (MatrixState.pangeaController.userController.profile.userSettings + .autoPlayMessages) { + toolbarMode = MessageMode.textToSpeech; + return; + } + setState(() {}); + } + + updateToolbarMode(MessageMode mode) { + setState(() { + toolbarMode = mode; + }); + } + + /// 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) { + return widget._pangeaMessageEvent.messageDisplayText; + } + + return widget._pangeaMessageEvent.messageDisplayText.substring( + _selectedSpan!.offset, + _selectedSpan!.offset + _selectedSpan!.length, + ); + } + + void onClickOverlayMessageToken( + PangeaToken token, + ) { + // if there's no selected span, then select the token + if (_selectedSpan == null) { + _selectedSpan = token.text; + } else { + // if there is a selected span, then deselect the token if it's the same + if (isTokenSelected(token)) { + _selectedSpan = null; + } else { + // if there is a selected span but it is not the same, then select the token + _selectedSpan = token.text; + } + } + + setState(() {}); + } + + void clearSelection() { + _selectedSpan = null; + setState(() {}); + } + + void onNewActivity(PracticeActivityModel activity) { + final RelevantSpanDisplayDetails? span = + activity.multipleChoice?.spanDisplayDetails; + + if (span == null) { + debugger(when: kDebugMode); + return; + } + + _selectedSpan = PangeaTokenText( + offset: span.offset, + length: span.length, + content: widget._pangeaMessageEvent.messageDisplayText + .substring(span.offset, span.offset + span.length), + ); + + setState(() {}); + } + + /// Whether the given token is currently selected + bool isTokenSelected(PangeaToken token) { + return _selectedSpan?.offset == token.text.offset && + _selectedSpan?.length == token.text.length; + } + + /// Whether the overlay is currently displaying a selection + bool get isSelection => _selectedSpan != null; + + PangeaTokenText? get selectedSpan => _selectedSpan; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -108,8 +230,8 @@ class MessageSelectionOverlayState extends State ), ); - widget.controller.scrollController.animateTo( - widget.controller.scrollController.offset - scrollOffset, + widget.chatController.scrollController.animateTo( + widget.chatController.scrollController.offset - scrollOffset, duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, ); @@ -123,7 +245,7 @@ class MessageSelectionOverlayState extends State } RenderBox? get messageRenderBox => MatrixState.pAnyState.getRenderBox( - widget.event.eventId, + widget._event.eventId, ); Size? get messageSize => messageRenderBox?.size; @@ -146,7 +268,7 @@ class MessageSelectionOverlayState extends State .getBool(SettingKeys.displayChatDetailsColumn) ?? false) && FluffyThemes.isThreeColumnMode(context) && - widget.controller.room.membership == Membership.join; + widget.chatController.room.membership == Membership.join; final overlayMessage = ConstrainedBox( constraints: const BoxConstraints( @@ -158,40 +280,38 @@ class MessageSelectionOverlayState extends State mainAxisSize: MainAxisSize.min, children: [ Row( - mainAxisAlignment: widget.pangeaMessageEvent.ownMessage + mainAxisAlignment: widget._pangeaMessageEvent.ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only( - left: widget.pangeaMessageEvent.ownMessage + left: widget._pangeaMessageEvent.ownMessage ? 0 : Avatar.defaultSize + 16, - right: widget.pangeaMessageEvent.ownMessage ? 8 : 0, + right: widget._pangeaMessageEvent.ownMessage ? 8 : 0, ), child: MessageToolbar( - pangeaMessageEvent: widget.pangeaMessageEvent, - controller: widget.controller, - textSelection: widget.textSelection, - initialMode: widget.initialMode, + pangeaMessageEvent: widget._pangeaMessageEvent, + overLayController: this, ), ), ], ), Message( - widget.event, + widget._event, onSwipe: () => {}, onInfoTab: (_) => {}, onAvatarTab: (_) => {}, scrollToEventId: (_) => {}, onSelect: (_) => {}, - immersionMode: widget.controller.choreographer.immersionMode, - controller: widget.controller, - timeline: widget.controller.timeline!, - isOverlay: true, + immersionMode: widget.chatController.choreographer.immersionMode, + controller: widget.chatController, + timeline: widget.chatController.timeline!, + overlayController: this, animateIn: false, - nextEvent: widget.nextEvent, - previousEvent: widget.prevEvent, + nextEvent: widget._nextEvent, + previousEvent: widget._prevEvent, ), ], ), @@ -240,7 +360,7 @@ class MessageSelectionOverlayState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - OverlayFooter(controller: widget.controller), + OverlayFooter(controller: widget.chatController), ], ), ), @@ -252,7 +372,7 @@ class MessageSelectionOverlayState extends State ), ), Material( - child: OverlayHeader(controller: widget.controller), + child: OverlayHeader(controller: widget.chatController), ), ], ), diff --git a/lib/pangea/widgets/chat/message_text_selection.dart b/lib/pangea/widgets/chat/message_text_selection.dart deleted file mode 100644 index 2396d08bf..000000000 --- a/lib/pangea/widgets/chat/message_text_selection.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:async'; - -/// Contains information about the text currently being shown in a -/// toolbar overlay message and any selection within that text. -/// The ChatController contains one instance of this class, and it's values -/// should be updated each time an overlay is openned or closed, or when -/// an overlay's text selection changes. -class MessageTextSelection { - /// The currently selected text in the overlay message. - String? selectedText; - - /// The full text displayed in the overlay message. - String? messageText; - - /// A stream that emits the currently selected text whenever it changes. - final StreamController selectionStream = - StreamController.broadcast(); - - /// Sets messageText to match the text currently being displayed in the overlay. - /// Text in messages is displayed in a variety of ways, i.e., direct message content, - /// translation, HTML rendered message, etc. This method should be called wherever the - /// text displayed in the overlay is determined. - void setMessageText(String text) => messageText = text; - - /// Clears the messageText value. Called when the message selection overlay is closed. - void clearMessageText() => messageText = null; - - /// Updates the selectedText value and emits it to the selectionStream. - void onSelection(String? text) { - text == null || text.isEmpty ? selectedText = null : selectedText = text; - selectionStream.add(selectedText); - } - - /// Returns the index of the selected text within the message text. - /// If the selected text is not found, returns null. - int? get offset { - if (selectedText == null || messageText == null) return null; - final index = messageText!.indexOf(selectedText!); - return index > -1 ? index : null; - } -} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index c18b0fe40..0ade95ab5 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -1,5 +1,7 @@ -import 'dart:async'; +import 'dart:developer'; +import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; @@ -7,29 +9,25 @@ import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart'; import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:matrix/matrix.dart'; class MessageToolbar extends StatefulWidget { - final MessageTextSelection textSelection; final PangeaMessageEvent pangeaMessageEvent; - final ChatController controller; - final MessageMode? initialMode; + final MessageOverlayController overLayController; const MessageToolbar({ super.key, - required this.textSelection, required this.pangeaMessageEvent, - required this.controller, - this.initialMode, + required this.overLayController, }); @override @@ -37,203 +35,113 @@ class MessageToolbar extends StatefulWidget { } class MessageToolbarState extends State { - Widget? toolbarContent; - MessageMode? currentMode; bool updatingMode = false; - late StreamSubscription selectionStream; - - void updateMode(MessageMode newMode) { - //Early exit from the function if the widget has been unmounted to prevent updates on an inactive widget. - if (!mounted) return; - if (updatingMode) return; - debugPrint("updating toolbar mode"); - final bool subscribed = - MatrixState.pangeaController.subscriptionController.isSubscribed; - - if (!newMode.isValidMode(widget.pangeaMessageEvent.event)) { - ErrorHandler.logError( - e: "Invalid mode for event", - s: StackTrace.current, - data: { - "newMode": newMode, - "event": widget.pangeaMessageEvent.event, - }, - ); - return; - } - - // if there is an uncompleted activity, then show that - // we don't want the user to user the tools to get the answer :P - if (widget.pangeaMessageEvent.hasUncompletedActivity) { - newMode = MessageMode.practiceActivity; - } - - if (mounted) { - setState(() { - currentMode = newMode; - updatingMode = true; - }); - } - - if (!subscribed) { - toolbarContent = MessageUnsubscribedCard( - languageTool: newMode.title(context), - mode: newMode, - controller: this, - ); - } else { - switch (currentMode) { - case MessageMode.translation: - showTranslation(); - break; - case MessageMode.textToSpeech: - showTextToSpeech(); - break; - case MessageMode.speechToText: - showSpeechToText(); - break; - case MessageMode.definition: - showDefinition(); - break; - case MessageMode.practiceActivity: - showPracticeActivity(); - break; - default: - ErrorHandler.logError( - e: "Invalid toolbar mode", - s: StackTrace.current, - data: {"newMode": newMode}, - ); - break; - } - } - if (mounted) { - setState(() { - updatingMode = false; - }); - } - } - - void showTranslation() { - debugPrint("show translation"); - toolbarContent = MessageTranslationCard( - messageEvent: widget.pangeaMessageEvent, - immersionMode: widget.controller.choreographer.immersionMode, - selection: widget.textSelection, - ); - } - - void showTextToSpeech() { - debugPrint("show text to speech"); - toolbarContent = MessageAudioCard( - messageEvent: widget.pangeaMessageEvent, - ); - } - - void showSpeechToText() { - debugPrint("show speech to text"); - toolbarContent = MessageSpeechToTextCard( - messageEvent: widget.pangeaMessageEvent, - ); - } - - void showDefinition() { - debugPrint("show definition"); - if (widget.textSelection.selectedText == null || - widget.textSelection.messageText == null || - widget.textSelection.selectedText!.isEmpty) { - toolbarContent = const SelectToDefine(); - return; - } - - toolbarContent = WordDataCard( - word: widget.textSelection.selectedText!, - wordLang: widget.pangeaMessageEvent.messageDisplayLangCode, - fullText: widget.textSelection.messageText!, - fullTextLang: widget.pangeaMessageEvent.messageDisplayLangCode, - hasInfo: true, - room: widget.controller.room, - ); - } - - void showPracticeActivity() { - toolbarContent = PracticeActivityCard( - pangeaMessageEvent: widget.pangeaMessageEvent, - ); - } - - void showImage() {} - - void spellCheck() {} @override void initState() { super.initState(); - widget.textSelection.selectedText = null; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (widget.pangeaMessageEvent.isAudioMessage) { - updateMode(MessageMode.speechToText); - return; - } + // why can't this just be initstate or the build mode? + // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // //determine the starting mode + // // if (widget.pangeaMessageEvent.isAudioMessage) { + // // updateMode(MessageMode.speechToText); + // // return; + // // } - if (widget.initialMode != null) { - updateMode(widget.initialMode!); - } else { - MatrixState.pangeaController.userController.profile.userSettings - .autoPlayMessages - ? updateMode(MessageMode.textToSpeech) - : updateMode(MessageMode.translation); - } - }); + // // if (widget.initialMode != null) { + // // updateMode(widget.initialMode!); + // // } else { + // // MatrixState.pangeaController.userController.profile.userSettings + // // .autoPlayMessages + // // ? updateMode(MessageMode.textToSpeech) + // // : updateMode(MessageMode.translation); + // // } + // // }); - Timer? timer; - selectionStream = - widget.textSelection.selectionStream.stream.listen((value) { - timer?.cancel(); - timer = Timer(const Duration(milliseconds: 500), () { - if (value != null && value.isNotEmpty) { - final MessageMode newMode = currentMode == MessageMode.definition - ? MessageMode.definition - : MessageMode.translation; - updateMode(newMode); - } else if (currentMode != null) { - updateMode(currentMode!); + // // just set mode based on messageSelectionOverlay mode which is now handling the state + // updateMode(widget.overLayController.toolbarMode); + // }); + } + + Widget get toolbarContent { + final bool subscribed = + MatrixState.pangeaController.subscriptionController.isSubscribed; + + if (!subscribed) { + return MessageUnsubscribedCard( + languageTool: widget.overLayController.toolbarMode.title(context), + mode: widget.overLayController.toolbarMode, + controller: this, + ); + } + + switch (widget.overLayController.toolbarMode) { + case MessageMode.translation: + return MessageTranslationCard( + messageEvent: widget.pangeaMessageEvent, + selection: widget.overLayController.selectedSpan, + ); + case MessageMode.textToSpeech: + return MessageAudioCard( + messageEvent: widget.pangeaMessageEvent, + ); + case MessageMode.speechToText: + return MessageSpeechToTextCard( + messageEvent: widget.pangeaMessageEvent, + ); + case MessageMode.definition: + if (!widget.overLayController.isSelection) { + return const SelectToDefine(); + } else { + try { + final selectedText = widget.overLayController.targetText; + + return WordDataCard( + word: selectedText, + wordLang: widget.pangeaMessageEvent.messageDisplayLangCode, + fullText: widget.pangeaMessageEvent.messageDisplayText, + fullTextLang: widget.pangeaMessageEvent.messageDisplayLangCode, + hasInfo: true, + room: widget.overLayController.widget.chatController.room, + ); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: "Error in WordDataCard", + s: s, + data: { + "word": widget.overLayController.targetText, + "fullText": widget.pangeaMessageEvent.messageDisplayText, + }, + ); + return const SizedBox(); + } } - }); - }); + case MessageMode.practiceActivity: + return PracticeActivityCard( + pangeaMessageEvent: widget.pangeaMessageEvent, + overlayController: widget.overLayController, + ); + default: + debugger(when: kDebugMode); + ErrorHandler.logError( + e: "Invalid toolbar mode", + s: StackTrace.current, + data: {"newMode": widget.overLayController.toolbarMode}, + ); + return const SizedBox(); + } } @override void dispose() { - selectionStream.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - final buttonRow = Row( - mainAxisSize: MainAxisSize.min, - children: MessageMode.values - .map( - (mode) => mode.isValidMode(widget.pangeaMessageEvent.event) - ? Tooltip( - message: mode.tooltip(context), - child: IconButton( - icon: Icon(mode.icon), - color: mode.iconColor( - widget.pangeaMessageEvent, - currentMode, - context, - ), - onPressed: () => updateMode(mode), - ), - ) - : const SizedBox.shrink(), - ) - .toList(), - ); - + debugPrint("building toolbar"); return Material( key: MatrixState.pAnyState .layerLinkAndKey('${widget.pangeaMessageEvent.eventId}-toolbar') @@ -245,7 +153,7 @@ class MessageToolbarState extends State { maxWidth: 275, minWidth: 275, ), - padding: const EdgeInsets.fromLTRB(20, 20, 20, 10), + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).cardColor, border: Border.all( @@ -259,16 +167,15 @@ class MessageToolbarState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (toolbarContent != null) - Flexible( - child: SingleChildScrollView( - child: AnimatedSize( - duration: FluffyThemes.animationDuration, - child: toolbarContent, - ), + Flexible( + child: SingleChildScrollView( + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: toolbarContent, ), ), - buttonRow, + ), + ToolbarButtons(messageToolbarController: this, width: 250), ], ), ), @@ -296,30 +203,133 @@ class ToolbarSelectionArea extends StatelessWidget { @override Widget build(BuildContext context) { - return SelectionArea( - onSelectionChanged: (SelectedContent? selection) { - controller.textSelection.onSelection(selection?.plainText); + return GestureDetector( + onTap: () { + if (pangeaMessageEvent != null && !isOverlay) { + controller.showToolbar( + pangeaMessageEvent!, + nextEvent: nextEvent, + prevEvent: prevEvent, + ); + } }, - child: GestureDetector( - onTap: () { - if (pangeaMessageEvent != null && !isOverlay) { - controller.showToolbar( - pangeaMessageEvent!, - nextEvent: nextEvent, - prevEvent: prevEvent, - ); - } - }, - onLongPress: () { - if (pangeaMessageEvent != null && !isOverlay) { - controller.showToolbar( - pangeaMessageEvent!, - nextEvent: nextEvent, - prevEvent: prevEvent, - ); - } - }, - child: child, + onLongPress: () { + if (pangeaMessageEvent != null && !isOverlay) { + controller.showToolbar( + pangeaMessageEvent!, + nextEvent: nextEvent, + prevEvent: prevEvent, + ); + } + }, + child: child, + ); + } +} + +class ToolbarButtons extends StatefulWidget { + final MessageToolbarState messageToolbarController; + final double width; + + const ToolbarButtons({ + required this.messageToolbarController, + required this.width, + super.key, + }); + + @override + ToolbarButtonsState createState() => ToolbarButtonsState(); +} + +class ToolbarButtonsState extends State { + PangeaMessageEvent get pangeaMessageEvent => + widget.messageToolbarController.widget.pangeaMessageEvent; + + List get modes => MessageMode.values + .where((mode) => mode.isValidMode(pangeaMessageEvent.event)) + .toList(); + + static const double iconWidth = 36.0; + double get progressWidth => widget.width / modes.length; + + // @ggurdin - maybe this can be stateless now? + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + child: Stack( + alignment: Alignment.center, + children: [ + Stack( + children: [ + Container( + width: widget.width, + height: 12, + decoration: BoxDecoration( + color: MessageModeExtension.barAndLockedButtonColor(context), + ), + margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), + ), + AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: 12, + width: min( + widget.width, + progressWidth * + pangeaMessageEvent.numberOfActivitiesCompleted, + ), + color: AppConfig.success, + margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: modes + .mapIndexed( + (index, mode) => Tooltip( + message: mode.tooltip(context), + child: IconButton( + iconSize: 20, + icon: Icon(mode.icon), + color: mode == + widget.messageToolbarController.widget + .overLayController.toolbarMode + ? Colors.white + : null, + isSelected: mode == + widget.messageToolbarController.widget + .overLayController.toolbarMode, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + mode.iconButtonColor( + context, + index, + widget.messageToolbarController.widget + .overLayController.toolbarMode, + pangeaMessageEvent.numberOfActivitiesCompleted, + ), + ), + ), + onPressed: mode.isUnlocked( + index, + pangeaMessageEvent.numberOfActivitiesCompleted, + ) + ? () => widget + .messageToolbarController.widget.overLayController + .updateToolbarMode(mode) + : null, + ), + ), + ) + .toList(), + ), + ], ), ); } diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index bcf837c0e..891a5493f 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -1,11 +1,11 @@ import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/inline_tooltip.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -13,13 +13,11 @@ import 'package:flutter/material.dart'; class MessageTranslationCard extends StatefulWidget { final PangeaMessageEvent messageEvent; - final bool immersionMode; - final MessageTextSelection selection; + final PangeaTokenText? selection; const MessageTranslationCard({ super.key, required this.messageEvent, - required this.immersionMode, required this.selection, }); @@ -30,10 +28,27 @@ class MessageTranslationCard extends StatefulWidget { class MessageTranslationCardState extends State { PangeaRepresentation? repEvent; String? selectionTranslation; - String? oldSelectedText; - bool _fetchingRepresentation = false; + bool _fetchingTranslation = false; - Future fetchRepresentation() async { + @override + void initState() { + print('MessageTranslationCard initState'); + super.initState(); + loadTranslation(); + } + + @override + void didUpdateWidget(covariant MessageTranslationCard oldWidget) { + // debugger(when: kDebugMode); + if (oldWidget.selection != widget.selection) { + debugPrint('selection changed'); + loadTranslation(); + } + super.didUpdateWidget(oldWidget); + } + + Future fetchRepresentationText() async { + // debugger(when: kDebugMode); if (l1Code == null) return; repEvent = widget.messageEvent @@ -49,48 +64,48 @@ class MessageTranslationCardState extends State { } } - Future translateSelection() async { - if (widget.selection.selectedText == null || - l1Code == null || - l2Code == null || - widget.selection.messageText == null) { - selectionTranslation = null; + Future fetchSelectedTextTranslation() async { + if (!mounted) return; + + final pangeaController = MatrixState.pangeaController; + + if (!pangeaController.languageController.languagesSet) { + selectionTranslation = widget.messageEvent.messageDisplayText; return; } - oldSelectedText = widget.selection.selectedText; - final String accessToken = - MatrixState.pangeaController.userController.accessToken; - - final resp = await FullTextTranslationRepo.translate( - accessToken: accessToken, + final FullTextTranslationResponseModel res = + await FullTextTranslationRepo.translate( + accessToken: pangeaController.userController.accessToken, request: FullTextTranslationRequestModel( - text: widget.selection.messageText!, + text: widget.messageEvent.messageDisplayText, + srcLang: widget.messageEvent.messageDisplayLangCode, tgtLang: l1Code!, + offset: widget.selection?.offset, + length: widget.selection?.length, userL1: l1Code!, userL2: l2Code!, - srcLang: widget.messageEvent.messageDisplayLangCode, - length: widget.selection.selectedText!.length, - offset: widget.selection.offset, ), ); - if (mounted) { - selectionTranslation = resp.bestTranslation; - } + selectionTranslation = res.translations.first; } - Future loadTranslation(Future Function() future) async { + Future loadTranslation() async { if (!mounted) return; - setState(() => _fetchingRepresentation = true); + + setState(() => _fetchingTranslation = true); + try { - await future(); + await (widget.selection != null + ? fetchSelectedTextTranslation() + : fetchRepresentationText()); } catch (err) { ErrorHandler.logError(e: err); } if (mounted) { - setState(() => _fetchingRepresentation = false); + setState(() => _fetchingTranslation = false); } } @@ -99,27 +114,6 @@ class MessageTranslationCardState extends State { String? get l2Code => MatrixState.pangeaController.languageController.activeL2Code(); - @override - void initState() { - super.initState(); - loadTranslation(() async { - final List futures = []; - futures.add(fetchRepresentation()); - if (widget.selection.selectedText != null) { - futures.add(translateSelection()); - } - await Future.wait(futures); - }); - } - - @override - void didUpdateWidget(covariant MessageTranslationCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldSelectedText != widget.selection.selectedText) { - loadTranslation(translateSelection); - } - } - void closeHint() { MatrixState.pangeaController.instructions.turnOffInstruction( InlineInstructions.l1Translation.toString(), @@ -144,23 +138,24 @@ class MessageTranslationCardState extends State { final bool isTextIdentical = selectionTranslation != null && widget.messageEvent.originalSent?.text == selectionTranslation; - return isWrittenInL1 || isTextIdentical; + return (isWrittenInL1 || isTextIdentical) && widget.messageEvent.ownMessage; } @override Widget build(BuildContext context) { - if (!_fetchingRepresentation && + print('MessageTranslationCard build'); + if (!_fetchingTranslation && repEvent == null && selectionTranslation == null) { return const CardErrorWidget(); } return Container( - child: _fetchingRepresentation + child: _fetchingTranslation ? const ToolbarContentLoadingIndicator() : Column( children: [ - selectionTranslation != null + widget.selection != null ? Text( selectionTranslation!, style: BotStyle.text(context), diff --git a/lib/pangea/widgets/chat/message_unsubscribed_card.dart b/lib/pangea/widgets/chat/message_unsubscribed_card.dart index d1ff5c343..00b9c352d 100644 --- a/lib/pangea/widgets/chat/message_unsubscribed_card.dart +++ b/lib/pangea/widgets/chat/message_unsubscribed_card.dart @@ -28,7 +28,7 @@ class MessageUnsubscribedCard extends StatelessWidget { if (inTrialWindow) { MatrixState.pangeaController.subscriptionController .activateNewUserTrial(); - controller.updateMode(mode); + controller.widget.overLayController.updateToolbarMode(mode); } else { MatrixState.pangeaController.subscriptionController .showPaywall(context); diff --git a/lib/pangea/widgets/chat/overlay_message_text.dart b/lib/pangea/widgets/chat/overlay_message_text.dart new file mode 100644 index 000000000..b99028037 --- /dev/null +++ b/lib/pangea/widgets/chat/overlay_message_text.dart @@ -0,0 +1,147 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class OverlayMessageText extends StatefulWidget { + final PangeaMessageEvent pangeaMessageEvent; + final MessageOverlayController overlayController; + + const OverlayMessageText({ + super.key, + required this.pangeaMessageEvent, + required this.overlayController, + }); + + @override + OverlayMessageTextState createState() => OverlayMessageTextState(); +} + +class OverlayMessageTextState extends State { + final PangeaController pangeaController = MatrixState.pangeaController; + List? tokens; + + @override + void initState() { + tokens = widget.pangeaMessageEvent.originalSent?.tokens; + if (widget.pangeaMessageEvent.originalSent != null && tokens == null) { + widget.pangeaMessageEvent.originalSent! + .tokensGlobal(context) + .then((tokens) { + // this isn't currently working because originalSent's _event is null + setState(() => this.tokens = tokens); + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final ownMessage = widget.pangeaMessageEvent.event.senderId == + Matrix.of(context).client.userID; + + final style = TextStyle( + color: ownMessage + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + height: 1.3, + fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, + ); + + if (tokens == null || tokens!.isEmpty) { + return Text( + widget.pangeaMessageEvent.event.calcLocalizedBodyFallback( + MatrixLocals(L10n.of(context)!), + hideReply: true, + ), + style: style, + ); + } + + int lastEnd = 0; + final List tokenPositions = []; + + for (int i = 0; i < tokens!.length; i++) { + final token = tokens![i]; + final start = token.start; + final end = token.end; + + if (lastEnd < start) { + tokenPositions.add(TokenPosition(start: lastEnd, end: start)); + } + + tokenPositions.add( + TokenPosition( + start: start, + end: end, + tokenIndex: i, + token: token, + ), + ); + lastEnd = end; + } + + //TODO - take out of build function of every message + return RichText( + text: TextSpan( + children: tokenPositions.map((tokenPosition) { + if (tokenPosition.token != null) { + final isSelected = + widget.overlayController.isTokenSelected(tokenPosition.token!); + return TextSpan( + recognizer: TapGestureRecognizer() + ..onTap = () { + print( + 'tokenPosition.tokenIndex: ${tokenPosition.tokenIndex}', + ); + widget.overlayController.onClickOverlayMessageToken( + tokenPosition.token!, + ); + setState(() {}); + }, + text: tokenPosition.token!.text.content, + style: style.merge( + TextStyle( + backgroundColor: isSelected + ? Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.4) + : Colors.white.withOpacity(0.4) + : Colors.transparent, + ), + ), + ); + } else { + return TextSpan( + text: widget.pangeaMessageEvent.event.body.substring( + tokenPosition.start, + tokenPosition.end, + ), + style: style, + ); + } + }).toList(), + ), + ); + } +} + +class TokenPosition { + final int start; + final int end; + final PangeaToken? token; + final int tokenIndex; + + const TokenPosition({ + required this.start, + required this.end, + this.token, + this.tokenIndex = -1, + }); +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart index 5378796af..321795e24 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup.dart @@ -1,5 +1,4 @@ import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:flutter/material.dart'; @@ -35,24 +34,25 @@ class AnalyticsPopup extends StatelessWidget { ), body: Padding( padding: const EdgeInsets.symmetric(vertical: 20), - child: constructsModel.constructs.isEmpty + child: constructsModel.constructList.isEmpty ? Center( child: Text(L10n.of(context)!.noDataFound), ) : ListView.builder( - itemCount: constructsModel.constructs.length, + itemCount: constructsModel.constructList.length, itemBuilder: (context, index) { return Tooltip( message: - "${constructsModel.constructs[index].points} / ${constructsModel.type.maxXPPerLemma}", + "${constructsModel.constructList[index].points} / ${constructsModel.maxXPPerLemma}", child: ListTile( onTap: () {}, title: Text( - constructsModel.constructs[index].lemma, + constructsModel.constructList[index].lemma, ), subtitle: LinearProgressIndicator( - value: constructsModel.constructs[index].points / - constructsModel.type.maxXPPerLemma, + value: + constructsModel.constructList[index].points / + constructsModel.maxXPPerLemma, minHeight: 20, borderRadius: const BorderRadius.all( Radius.circular(AppConfig.borderRadius), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index eb0b0cb62..5c1c3337f 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_ import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -142,7 +143,9 @@ class LearningProgressIndicatorsState final progressBar = ProgressBar( levelBars: [ LevelBarDetails( - fillColor: const Color.fromARGB(255, 0, 190, 83), + fillColor: kDebugMode + ? const Color.fromARGB(255, 0, 190, 83) + : Theme.of(context).colorScheme.primary, currentPoints: currentXP, ), LevelBarDetails( diff --git a/lib/pangea/widgets/igc/pangea_rich_text.dart b/lib/pangea/widgets/igc/pangea_rich_text.dart index cdb102414..e0b0e95ac 100644 --- a/lib/pangea/widgets/igc/pangea_rich_text.dart +++ b/lib/pangea/widgets/igc/pangea_rich_text.dart @@ -67,9 +67,6 @@ class PangeaRichTextState extends State { if (!mounted) return; // Early exit if the widget is no longer in the tree setState(() { textSpan = newTextSpan; - if (widget.isOverlay) { - widget.controller.textSelection.setMessageText(textSpan); - } }); } catch (error, stackTrace) { ErrorHandler.logError( diff --git a/lib/pangea/widgets/igc/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index 5ddf1b0c5..97fb2cfe4 100644 --- a/lib/pangea/widgets/igc/span_card.dart +++ b/lib/pangea/widgets/igc/span_card.dart @@ -120,7 +120,7 @@ class SpanCardState extends State { } } - Future onChoiceSelect(int index) async { + Future onChoiceSelect(String value, int index) async { selectedChoiceIndex = index; if (selectedChoice != null) { if (!selectedChoice!.selected) { diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index 1d789424d..aeb41d08b 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -76,6 +76,7 @@ class WordDataCardController extends State { @override void didUpdateWidget(covariant WordDataCard oldWidget) { + // debugger(when: kDebugMode); if (oldWidget.word != widget.word) { if (!widget.hasInfo) { getContextualDefinition(); diff --git a/lib/pangea/widgets/practice_activity/generate_practice_activity.dart b/lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart similarity index 57% rename from lib/pangea/widgets/practice_activity/generate_practice_activity.dart rename to lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart index 1eae97d63..f2e20dcd8 100644 --- a/lib/pangea/widgets/practice_activity/generate_practice_activity.dart +++ b/lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart @@ -1,6 +1,5 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -33,27 +32,29 @@ class GeneratePracticeActivityButton extends StatelessWidget { return; } - final PracticeActivityEvent? practiceActivityEvent = await MatrixState - .pangeaController.practiceGenerationController - .getPracticeActivity( - PracticeActivityRequest( - candidateMessages: [ - CandidateMessage( - msgId: pangeaMessageEvent.eventId, - roomId: pangeaMessageEvent.room.id, - text: - pangeaMessageEvent.representationByLanguage(l2Code)?.text ?? - pangeaMessageEvent.body, - ), - ], - userIds: pangeaMessageEvent.room.client.userID != null - ? [pangeaMessageEvent.room.client.userID!] - : null, - ), - pangeaMessageEvent, - ); + throw UnimplementedError(); - onActivityGenerated(practiceActivityEvent); + // final PracticeActivityEvent? practiceActivityEvent = await MatrixState + // .pangeaController.practiceGenerationController + // .getPracticeActivity( + // MessageActivityRequest( + // candidateMessages: [ + // CandidateMessage( + // msgId: pangeaMessageEvent.eventId, + // roomId: pangeaMessageEvent.room.id, + // text: + // pangeaMessageEvent.representationByLanguage(l2Code)?.text ?? + // pangeaMessageEvent.body, + // ), + // ], + // userIds: pangeaMessageEvent.room.client.userID != null + // ? [pangeaMessageEvent.room.client.userID!] + // : null, + // ), + // pangeaMessageEvent, + // ); + + // onActivityGenerated(practiceActivityEvent); }, child: Text(L10n.of(context)!.practice), ); diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 54ff5586c..65b6f0704 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; +import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; @@ -8,12 +9,12 @@ import 'package:flutter/material.dart'; /// The multiple choice activity view class MultipleChoiceActivity extends StatefulWidget { - final MessagePracticeActivityCardState controller; + final MessagePracticeActivityCardState practiceCardController; final PracticeActivityEvent? currentActivity; const MultipleChoiceActivity({ super.key, - required this.controller, + required this.practiceCardController, required this.currentActivity, }); @@ -25,7 +26,7 @@ class MultipleChoiceActivityState extends State { int? selectedChoiceIndex; PracticeActivityRecordModel? get currentRecordModel => - widget.controller.currentRecordModel; + widget.practiceCardController.currentCompletionRecord; bool get isSubmitted => widget.currentActivity?.userRecord?.record.latestResponse != null; @@ -52,7 +53,7 @@ class MultipleChoiceActivityState extends State { /// determines the selected choice index. void setCompletionRecord() { if (widget.currentActivity?.userRecord?.record == null) { - widget.controller.setCurrentModel( + widget.practiceCardController.setCompletionRecord( PracticeActivityRecordModel( question: widget.currentActivity?.practiceActivity.multipleChoice!.question, @@ -60,8 +61,8 @@ class MultipleChoiceActivityState extends State { ); selectedChoiceIndex = null; } else { - widget.controller - .setCurrentModel(widget.currentActivity!.userRecord!.record); + widget.practiceCardController + .setCompletionRecord(widget.currentActivity!.userRecord!.record); selectedChoiceIndex = widget .currentActivity?.practiceActivity.multipleChoice! .choiceIndex(currentRecordModel!.latestResponse!.text!); @@ -69,16 +70,41 @@ class MultipleChoiceActivityState extends State { setState(() {}); } - void updateChoice(int index) { + void updateChoice(String value, int index) { + if (currentRecordModel?.hasTextResponse(value) ?? false) { + return; + } + + final bool isCorrect = widget + .currentActivity!.practiceActivity.multipleChoice! + .isCorrect(value, index); + + final ConstructUseTypeEnum useType = + isCorrect ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + currentRecordModel?.addResponse( - text: widget.controller.currentActivity!.practiceActivity.multipleChoice! - .choices[index], - score: widget.controller.currentActivity!.practiceActivity.multipleChoice! - .isCorrect(index) - ? 1 - : 0, + text: value, + score: isCorrect ? 1 : 0, + ); + + // TODO - add draft uses + // activities currently pass around tgtConstructs but not the token + // either we change addDraftUses to take constructs or we get and pass the token + // MatrixState.pangeaController.myAnalytics.addDraftUses( + // widget.currentActivity.practiceActivity.tg, + // widget.practiceCardController.widget.pangeaMessageEvent.room.id, + // useType, + // ); + + // If the selected choice is correct, send the record and get the next activity + if (widget.currentActivity!.practiceActivity.multipleChoice! + .isCorrect(value, index)) { + widget.practiceCardController.onActivityFinish(); + } + + setState( + () => selectedChoiceIndex = index, ); - setState(() => selectedChoiceIndex = index); } @override @@ -112,10 +138,11 @@ class MultipleChoiceActivityState extends State { .mapIndexed( (index, value) => Choice( text: value, - color: selectedChoiceIndex == index + color: currentRecordModel?.hasTextResponse(value) ?? false ? practiceActivity.multipleChoice!.choiceColor(index) : null, - isGold: practiceActivity.multipleChoice!.isCorrect(index), + isGold: practiceActivity.multipleChoice! + .isCorrect(value, index), ), ) .toList(), diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 6e24c9e8b..03935060e 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,25 +1,39 @@ -import 'package:fluffychat/config/app_config.dart'; +import 'dart:async'; +import 'dart:developer'; + import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.dart'; +import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; /// The wrapper for practice activity content. -/// Handles the activities assosiated with a message, +/// Handles the activities associated with a message, /// their navigation, and the management of completion records class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; + final MessageOverlayController overlayController; const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, + required this.overlayController, }); @override @@ -29,8 +43,10 @@ class PracticeActivityCard extends StatefulWidget { class MessagePracticeActivityCardState extends State { PracticeActivityEvent? currentActivity; - PracticeActivityRecordModel? currentRecordModel; - bool sending = false; + PracticeActivityRecordModel? currentCompletionRecord; + bool fetchingActivity = false; + + List targetTokens = []; List get practiceActivities => widget.pangeaMessageEvent.practiceActivities; @@ -39,148 +55,327 @@ class MessagePracticeActivityCardState extends State { (activity) => activity.event.eventId == currentActivity?.event.eventId, ); - bool get isPrevEnabled => - practiceEventIndex > 0 && - practiceActivities.length > (practiceEventIndex - 1); - - bool get isNextEnabled => - practiceEventIndex >= 0 && - practiceEventIndex < practiceActivities.length - 1; + /// TODO - @ggurdin - how can we start our processes (saving results and getting an activity) + /// immediately after a correct choice but wait to display until x milliseconds after the choice is made AND + /// we've received the new activity? + Duration appropriateTimeForJoy = const Duration(milliseconds: 500); + bool savoringTheJoy = false; + Timer? joyTimer; @override void initState() { super.initState(); - setCurrentActivity(); + initialize(); } - /// Initalizes the current activity. - /// If the current activity hasn't been set yet, show the first - /// uncompleted activity if there is one. - /// If not, show the first activity - void setCurrentActivity() { - if (practiceActivities.isEmpty) return; + void updateFetchingActivity(bool value) { + if (fetchingActivity == value) return; + setState(() => fetchingActivity = value); + } + + /// Get an activity to display. + /// Show an uncompleted activity if there is one. + /// If not, get a new activity from the server. + Future initialize() async { + targetTokens = await getTargetTokens(); + + currentActivity = _fetchExistingActivity() ?? await _fetchNewActivity(); + + currentActivity == null + ? widget.overlayController.exitPracticeFlow() + : widget.overlayController + .onNewActivity(currentActivity!.practiceActivity); + } + + // TODO - do more of a check for whether we have an appropropriate activity + // if the user did the activity before but awhile ago and we don't have any + // more target tokens, maybe we should give them the same activity again + PracticeActivityEvent? _fetchExistingActivity() { final List incompleteActivities = practiceActivities.where((element) => !element.isComplete).toList(); - currentActivity ??= incompleteActivities.isNotEmpty - ? incompleteActivities.first - : practiceActivities.first; - setState(() {}); + + final PracticeActivityEvent? existingActivity = + incompleteActivities.isNotEmpty ? incompleteActivities.first : null; + + return existingActivity != null && + existingActivity.practiceActivity != + currentActivity?.practiceActivity + ? existingActivity + : null; } - void setCurrentModel(PracticeActivityRecordModel? recordModel) { - currentRecordModel = recordModel; - } + Future _fetchNewActivity() async { + updateFetchingActivity(true); - /// Sets the current acitivity based on the given [direction]. - void navigateActivities(Direction direction) { - final bool enableNavigation = (direction == Direction.f && isNextEnabled) || - (direction == Direction.b && isPrevEnabled); - if (enableNavigation) { - currentActivity = practiceActivities[direction == Direction.f - ? practiceEventIndex + 1 - : practiceEventIndex - 1]; - setState(() {}); + if (targetTokens.isEmpty || + !pangeaController.languageController.languagesSet) { + debugger(when: kDebugMode); + return null; } + + final ourNewActivity = + await pangeaController.practiceGenerationController.getPracticeActivity( + MessageActivityRequest( + userL1: pangeaController.languageController.userL1!.langCode, + userL2: pangeaController.languageController.userL2!.langCode, + messageText: representation!.text, + tokensWithXP: targetTokens, + messageId: widget.pangeaMessageEvent.eventId, + ), + widget.pangeaMessageEvent, + ); + + /// Removes the target tokens of the new activity from the target tokens list. + /// This avoids getting activities for the same token again, at least + /// until the user exists the toolbar and re-enters it. By then, the + /// analytics stream will have updated and the user will be able to get + /// activity data for previously targeted tokens. This should then exclude + /// the tokens that were targeted in previous activities based on xp and lastUsed. + if (ourNewActivity?.practiceActivity.relevantSpanDisplayDetails != null) { + targetTokens.removeWhere((token) { + final RelevantSpanDisplayDetails span = + ourNewActivity!.practiceActivity.relevantSpanDisplayDetails!; + return token.token.text.offset >= span.offset && + token.token.text.offset + token.token.text.length <= + span.offset + span.length; + }); + } + + updateFetchingActivity(false); + + return ourNewActivity; + } + + RepresentationEvent? get representation => + widget.pangeaMessageEvent.originalSent; + + String get messsageText => representation!.text; + + PangeaController get pangeaController => MatrixState.pangeaController; + + /// From the tokens in the message, do a preliminary filtering of which to target + /// Then get the construct uses for those tokens + Future> getTargetTokens() async { + if (!mounted) { + ErrorHandler.logError( + m: 'getTargetTokens called when not mounted', + s: StackTrace.current, + ); + return []; + } + + // we're just going to set this once per session + // we remove the target tokens when we get a new activity + if (targetTokens.isNotEmpty) return targetTokens; + + if (representation == null) { + debugger(when: kDebugMode); + return []; + } + final tokens = await representation?.tokensGlobal(context); + + if (tokens == null || tokens.isEmpty) { + debugger(when: kDebugMode); + return []; + } + + var constructUses = + MatrixState.pangeaController.analytics.analyticsStream.value; + + if (constructUses == null || constructUses.isEmpty) { + constructUses = []; + //@gurdin - this is happening for me with a brand-new user. however, in this case, constructUses should be empty list + debugger(when: kDebugMode); + } + + final ConstructListModel constructList = ConstructListModel( + uses: constructUses, + type: null, + ); + + final List tokenCounts = []; + + // TODO - add morph constructs to this list as well + for (int i = 0; i < tokens.length; i++) { + //don't bother with tokens that we don't save to vocab + if (!tokens[i].lemma.saveVocab) { + continue; + } + + tokenCounts.add(tokens[i].emptyTokenWithXP); + + for (final construct in tokenCounts.last.constructs) { + final constructUseModel = constructList.getConstructUses( + construct.id.lemma, + construct.id.type, + ); + if (constructUseModel != null) { + construct.xp = constructUseModel.points; + } + } + } + + tokenCounts.sort((a, b) => a.xp.compareTo(b.xp)); + + return tokenCounts; + } + + void setCompletionRecord(PracticeActivityRecordModel? recordModel) { + currentCompletionRecord = recordModel; + } + + /// future that simply waits for the appropriate time to savor the joy + Future savorTheJoy() async { + if (savoringTheJoy) return; + savoringTheJoy = true; + joyTimer = Timer(appropriateTimeForJoy, () { + savoringTheJoy = false; + joyTimer?.cancel(); + }); } /// Sends the current record model and activity to the server. /// If either the currentRecordModel or currentActivity is null, the method returns early. - /// Sets the [sending] flag to true before sending the record and activity. - /// Logs any errors that occur during the send operation. - /// Sets the [sending] flag to false when the send operation is complete. - void sendRecord() { - if (currentRecordModel == null || currentActivity == null) return; - setState(() => sending = true); - MatrixState.pangeaController.activityRecordController - .send(currentRecordModel!, currentActivity!) - .catchError((error) { + /// If the currentActivity is the last activity, the method sets the appropriate flag to true. + /// If the currentActivity is not the last activity, the method fetches a new activity. + void onActivityFinish() async { + try { + if (currentCompletionRecord == null || currentActivity == null) { + debugger(when: kDebugMode); + return; + } + + joyTimer?.cancel(); + savoringTheJoy = true; + joyTimer = Timer(appropriateTimeForJoy, () { + if (!mounted) return; + savoringTheJoy = false; + joyTimer?.cancel(); + }); + + // if this is the last activity, set the flag to true + // so we can give them some kudos + if (widget.overlayController.activitiesLeftToComplete == 1) { + widget.overlayController.finishedActivitiesThisSession = true; + } + + final Event? event = await MatrixState + .pangeaController.activityRecordController + .send(currentCompletionRecord!, currentActivity!); + + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + eventId: widget.pangeaMessageEvent.eventId, + eventType: PangeaEventTypes.activityRecord, + roomId: event!.room.id, + practiceActivity: currentActivity!, + recordModel: currentCompletionRecord!, + ), + ); + + if (!widget.overlayController.finishedActivitiesThisSession) { + currentActivity = await _fetchNewActivity(); + + currentActivity == null + ? widget.overlayController.exitPracticeFlow() + : widget.overlayController + .onNewActivity(currentActivity!.practiceActivity); + } else { + updateFetchingActivity(false); + widget.overlayController.setState(() {}); + } + } catch (e, s) { + debugger(when: kDebugMode); ErrorHandler.logError( - e: error, - s: StackTrace.current, + e: e, + s: s, + m: 'Failed to send record for activity', data: { - 'recordModel': currentRecordModel?.toJson(), - 'practiceEvent': currentActivity?.event.toJson(), + 'activity': currentActivity, + 'record': currentCompletionRecord, }, ); - return null; - }).then((event) { - // The record event is processed into construct uses for learning analytics, so if the - // event went through without error, send it to analytics to be processed - if (event != null && currentActivity != null) { - MatrixState.pangeaController.myAnalytics.setState( + widget.overlayController.exitPracticeFlow(); + } + } + + Widget get activityWidget { + if (currentActivity == null) { + // return sizedbox with height of 80 + return const SizedBox(height: 80); + } + switch (currentActivity!.practiceActivity.activityType) { + case ActivityTypeEnum.multipleChoice: + return MultipleChoiceActivity( + practiceCardController: this, + currentActivity: currentActivity, + ); + default: + ErrorHandler.logError( + e: Exception('Unknown activity type'), + m: 'Unknown activity type', data: { - 'eventID': widget.pangeaMessageEvent.eventId, - 'eventType': PangeaEventTypes.activityRecord, - 'roomID': event.room.id, - 'practiceActivity': currentActivity!, - 'recordModel': currentRecordModel!, + 'activityType': currentActivity!.practiceActivity.activityType, }, ); - } - }).whenComplete(() => setState(() => sending = false)); + return Text( + L10n.of(context)!.oopsSomethingWentWrong, + style: BotStyle.text(context), + ); + } } @override Widget build(BuildContext context) { - final Widget navigationButtons = Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Opacity( - opacity: isPrevEnabled ? 1.0 : 0, - child: IconButton( - onPressed: - isPrevEnabled ? () => navigateActivities(Direction.b) : null, - icon: const Icon(Icons.keyboard_arrow_left_outlined), - tooltip: L10n.of(context)!.previous, - ), - ), - Expanded( - child: Opacity( - opacity: currentActivity?.userRecord == null ? 1.0 : 0.5, - child: sending - ? const CircularProgressIndicator.adaptive() - : TextButton( - onPressed: - currentActivity?.userRecord == null ? sendRecord : null, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - AppConfig.primaryColor, - ), - ), - child: Text(L10n.of(context)!.submit), - ), - ), - ), - Opacity( - opacity: isNextEnabled ? 1.0 : 0, - child: IconButton( - onPressed: - isNextEnabled ? () => navigateActivities(Direction.f) : null, - icon: const Icon(Icons.keyboard_arrow_right_outlined), - tooltip: L10n.of(context)!.next, - ), - ), - ], - ); - - if (currentActivity == null || practiceActivities.isEmpty) { - return Text( - L10n.of(context)!.noActivitiesFound, - style: BotStyle.text(context), - ); - // return GeneratePracticeActivityButton( - // pangeaMessageEvent: widget.pangeaMessageEvent, - // onActivityGenerated: updatePracticeActivity, - // ); + String? userMessage; + if (widget.overlayController.finishedActivitiesThisSession) { + userMessage = "Boom! Tools unlocked!"; + } else if (!fetchingActivity && currentActivity == null) { + userMessage = L10n.of(context)!.noActivitiesFound; } - return Column( - children: [ - PracticeActivity( - practiceEvent: currentActivity!, - controller: this, + + if (userMessage != null) { + return Center( + child: Container( + constraints: const BoxConstraints( + minHeight: 80, + ), + child: Text( + userMessage, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), ), - navigationButtons, + ); + } + + return Stack( + alignment: Alignment.topCenter, + children: [ + // Main content + const Positioned( + top: 40, + child: PointsGainedAnimation(), + ), + Column( + children: [ + activityWidget, + // navigationButtons, + ], + ), + // Conditionally show the darkening and progress indicator based on the loading state + if (!savoringTheJoy && fetchingActivity) ...[ + // Semi-transparent overlay + Container( + color: Colors.black.withOpacity(0.5), // Darkening effect + ), + // Circular progress indicator in the center + const Center( + child: CircularProgressIndicator(), + ), + ], ], ); } diff --git a/lib/pangea/widgets/practice_activity/practice_activity_content.dart b/lib/pangea/widgets/practice_activity/practice_activity_content.dart deleted file mode 100644 index 6de31829c..000000000 --- a/lib/pangea/widgets/practice_activity/practice_activity_content.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; -import 'package:flutter/material.dart'; - -/// Practice activity content -class PracticeActivity extends StatefulWidget { - final PracticeActivityEvent practiceEvent; - final MessagePracticeActivityCardState controller; - - const PracticeActivity({ - super.key, - required this.practiceEvent, - required this.controller, - }); - - @override - PracticeActivityContentState createState() => PracticeActivityContentState(); -} - -class PracticeActivityContentState extends State { - Widget get activityWidget { - switch (widget.practiceEvent.practiceActivity.activityType) { - case ActivityTypeEnum.multipleChoice: - return MultipleChoiceActivity( - controller: widget.controller, - currentActivity: widget.practiceEvent, - ); - default: - return const SizedBox.shrink(); - } - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - activityWidget, - const SizedBox(height: 8), - ], - ); - } -} diff --git a/lib/pangea/widgets/user_settings/p_language_dialog.dart b/lib/pangea/widgets/user_settings/p_language_dialog.dart index 43096ea54..b04b0e435 100644 --- a/lib/pangea/widgets/user_settings/p_language_dialog.dart +++ b/lib/pangea/widgets/user_settings/p_language_dialog.dart @@ -92,7 +92,7 @@ Future pLanguageDialog( future: () async { try { pangeaController.myAnalytics - .updateAnalytics() + .sendLocalAnalyticsToAnalyticsRoom() .then((_) { pangeaController.userController.updateProfile( (profile) { From 7c61ee70260b0ef7e83325f2d979652e7d57827a Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Thu, 26 Sep 2024 07:29:18 -0400 Subject: [PATCH 5/7] dang wrong .env --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 36add47dc..6be6edc91 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env.local_choreo"); + await dotenv.load(fileName: ".env"); } catch (e) { Logs().e('Failed to load .env file', e); } From f589d2371b96dee825fda7c208f2b6249e678f67 Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Thu, 26 Sep 2024 13:47:51 -0400 Subject: [PATCH 6/7] exit practice if no activities to do --- env.ocal_choreo | 16 ++++++ lib/main.dart | 2 +- ...actice_activity_generation_controller.dart | 14 +++-- lib/pangea/enum/message_mode_enum.dart | 6 ++- lib/pangea/models/igc_text_data_model.dart | 21 ++++++-- .../message_activity_request.dart | 28 ++++++++++ .../chat/message_selection_overlay.dart | 14 +++-- lib/pangea/widgets/chat/message_toolbar.dart | 13 ++++- .../practice_activity_card.dart | 53 ++++++++++--------- 9 files changed, 128 insertions(+), 39 deletions(-) create mode 100644 env.ocal_choreo diff --git a/env.ocal_choreo b/env.ocal_choreo new file mode 100644 index 000000000..36c87c253 --- /dev/null +++ b/env.ocal_choreo @@ -0,0 +1,16 @@ +BASE_API='https://api.staging.pangea.chat/api/v1' +CHOREO_API = "http://localhost:8000/choreo" +FRONTEND_URL='https://app.pangea.chat' + +SYNAPSE_URL = 'matrix.staging.pangea.chat' +CHOREO_API_KEY = 'e6fa9fa97031ba0c852efe78457922f278a2fbc109752fe18e465337699e9873' + +RC_PROJECT = 'a499dc21' +RC_KEY = 'sk_eVGBdPyInaOfJrKlPBgFVnRynqKJB' + +RC_GOOGLE_KEY = 'goog_paQMrzFKGzuWZvcMTPkkvIsifJe' +RC_IOS_KEY = 'appl_DUPqnxuLjkBLzhBPTWeDjqNENuv' +RC_STRIPE_KEY = 'strp_YWZxWUeEfvagiefDNoofinaRCOl' +RC_OFFERING_NAME = 'test' + +STRIPE_MANAGEMENT_LINK = 'https://billing.stripe.com/p/login/test_9AQaI8d3O9lmaXe5kk' \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6be6edc91..36add47dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env"); + await dotenv.load(fileName: ".env.local_choreo"); } catch (e) { Logs().e('Failed to load .env file', e); } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index b487dc726..fe2ebb388 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -76,7 +76,7 @@ class PracticeGenerationController { ); } - Future _fetch({ + Future _fetch({ required String accessToken, required MessageActivityRequest requestModel, }) async { @@ -92,7 +92,7 @@ class PracticeGenerationController { if (res.statusCode == 200) { final Map json = jsonDecode(utf8.decode(res.bodyBytes)); - final response = PracticeActivityModel.fromJson(json); + final response = MessageActivityResponse.fromJson(json); return response; } else { @@ -101,6 +101,8 @@ class PracticeGenerationController { } } + //TODO - allow return of activity content before sending the event + // this requires some downstream changes to the way the event is handled Future getPracticeActivity( MessageActivityRequest req, PangeaMessageEvent event, @@ -112,13 +114,17 @@ class PracticeGenerationController { } else { //TODO - send request to server/bot, either via API or via event of type pangeaActivityReq // for now, just make and send the event from the client - final PracticeActivityModel activity = await _fetch( + final MessageActivityResponse res = await _fetch( accessToken: _pangeaController.userController.accessToken, requestModel: req, ); + if (res.activity == null) { + return null; + } + final Future eventFuture = - _sendAndPackageEvent(activity, event); + _sendAndPackageEvent(res.activity!, event); _cache[cacheKey] = _RequestCacheItem(req: req, practiceActivityEvent: eventFuture); diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index 780b8f9b7..c8659f0fc 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -82,17 +82,19 @@ extension MessageModeExtension on MessageMode { bool isUnlocked( int index, int numActivitiesCompleted, + bool totallyDone, ) => - numActivitiesCompleted >= index; + numActivitiesCompleted >= index || totallyDone; Color iconButtonColor( BuildContext context, int index, MessageMode currentMode, int numActivitiesCompleted, + bool totallyDone, ) { //locked - if (!isUnlocked(index, numActivitiesCompleted)) { + if (!isUnlocked(index, numActivitiesCompleted, totallyDone)) { return barAndLockedButtonColor(context); } diff --git a/lib/pangea/models/igc_text_data_model.dart b/lib/pangea/models/igc_text_data_model.dart index be64491c4..180273e30 100644 --- a/lib/pangea/models/igc_text_data_model.dart +++ b/lib/pangea/models/igc_text_data_model.dart @@ -144,15 +144,30 @@ class IGCTextData { pangeaMatch.match.offset + pangeaMatch.match.length, ) + 1; - // replace the tokens in the list - tokens.replaceRange(startIndex, endIndex, replacement.tokens); - //for all tokens after the replacement, update their offsets + // for all tokens after the replacement, update their offsets for (int i = endIndex; i < tokens.length; i++) { final PangeaToken token = tokens[i]; token.text.offset += replacement.value.length - pangeaMatch.match.length; } + // clone the list for debugging purposes + final List newTokens = List.from(tokens); + + // replace the tokens in the list + newTokens.replaceRange(startIndex, endIndex, replacement.tokens); + + final String newFullText = PangeaToken.reconstructText(newTokens); + + if (newFullText != originalInput) { + debugger(when: kDebugMode); + ErrorHandler.logError( + m: "reconstructed text does not match original input", + ); + } + + tokens = newTokens; + //update offsets in existing matches to reflect the change //Question - remove matches that overlap with the accepted one? // see case of "quiero ver un fix" diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart index 5639057db..671df8ec6 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -148,3 +148,31 @@ class MessageActivityRequest { return messageId.hashCode ^ const ListEquality().hash(tokensWithXP); } } + +class MessageActivityResponse { + final PracticeActivityModel? activity; + final bool finished; + + MessageActivityResponse({ + required this.activity, + required this.finished, + }); + + factory MessageActivityResponse.fromJson(Map json) { + return MessageActivityResponse( + activity: json['activity'] != null + ? PracticeActivityModel.fromJson( + json['activity'] as Map, + ) + : null, + finished: json['finished'] as bool, + ); + } + + Map toJson() { + return { + 'activity': activity?.toJson(), + 'finished': finished, + }; + } +} diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 72d87df4a..e1774ac98 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -52,6 +52,7 @@ class MessageOverlayController extends State PangeaTokenText? _selectedSpan; /// The number of activities that need to be completed before the toolbar is unlocked + /// If we don't have any good activities for them, we'll decrease this number int needed = 3; /// Whether the user has completed the activities needed to unlock the toolbar @@ -73,6 +74,8 @@ class MessageOverlayController extends State int get activitiesLeftToComplete => needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; + bool get isPracticeComplete => activitiesLeftToComplete <= 0; + /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { @@ -82,13 +85,13 @@ class MessageOverlayController extends State } Future setInitialToolbarMode() async { - if (activitiesLeftToComplete > 0) { - toolbarMode = MessageMode.practiceActivity; + if (widget._pangeaMessageEvent.isAudioMessage) { + toolbarMode = MessageMode.speechToText; return; } - if (widget._pangeaMessageEvent.isAudioMessage) { - toolbarMode = MessageMode.speechToText; + if (activitiesLeftToComplete > 0) { + toolbarMode = MessageMode.practiceActivity; return; } @@ -97,6 +100,9 @@ class MessageOverlayController extends State toolbarMode = MessageMode.textToSpeech; return; } + + toolbarMode = MessageMode.translation; + setState(() {}); } diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 0ade95ab5..deef7f232 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -250,7 +250,10 @@ class ToolbarButtonsState extends State { .toList(); static const double iconWidth = 36.0; - double get progressWidth => widget.width / modes.length; + double get progressWidth => widget.width / overlayController.needed; + + MessageOverlayController get overlayController => + widget.messageToolbarController.widget.overLayController; // @ggurdin - maybe this can be stateless now? @override @@ -260,6 +263,11 @@ class ToolbarButtonsState extends State { @override Widget build(BuildContext context) { + if (widget + .messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) { + return const SizedBox(); + } + return SizedBox( width: widget.width, child: Stack( @@ -313,12 +321,15 @@ class ToolbarButtonsState extends State { widget.messageToolbarController.widget .overLayController.toolbarMode, pangeaMessageEvent.numberOfActivitiesCompleted, + widget.messageToolbarController.widget + .overLayController.isPracticeComplete, ), ), ), onPressed: mode.isUnlocked( index, pangeaMessageEvent.numberOfActivitiesCompleted, + overlayController.isPracticeComplete, ) ? () => widget .messageToolbarController.widget.overLayController diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 03935060e..d118ca684 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -110,6 +110,7 @@ class MessagePracticeActivityCardState extends State { if (targetTokens.isEmpty || !pangeaController.languageController.languagesSet) { debugger(when: kDebugMode); + updateFetchingActivity(false); return null; } @@ -146,13 +147,6 @@ class MessagePracticeActivityCardState extends State { return ourNewActivity; } - RepresentationEvent? get representation => - widget.pangeaMessageEvent.originalSent; - - String get messsageText => representation!.text; - - PangeaController get pangeaController => MatrixState.pangeaController; - /// From the tokens in the message, do a preliminary filtering of which to target /// Then get the construct uses for those tokens Future> getTargetTokens() async { @@ -226,6 +220,7 @@ class MessagePracticeActivityCardState extends State { /// future that simply waits for the appropriate time to savor the joy Future savorTheJoy() async { + joyTimer?.cancel(); if (savoringTheJoy) return; savoringTheJoy = true; joyTimer = Timer(appropriateTimeForJoy, () { @@ -245,13 +240,8 @@ class MessagePracticeActivityCardState extends State { return; } - joyTimer?.cancel(); - savoringTheJoy = true; - joyTimer = Timer(appropriateTimeForJoy, () { - if (!mounted) return; - savoringTheJoy = false; - joyTimer?.cancel(); - }); + // start joy timer + savorTheJoy(); // if this is the last activity, set the flag to true // so we can give them some kudos @@ -299,6 +289,17 @@ class MessagePracticeActivityCardState extends State { } } + RepresentationEvent? get representation => + widget.pangeaMessageEvent.originalSent; + + String get messsageText => representation!.text; + + PangeaController get pangeaController => MatrixState.pangeaController; + + /// The widget that displays the current activity. + /// If there is no current activity, the widget returns a sizedbox with a height of 80. + /// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity. + /// If the activity type is unknown, the widget logs an error and returns a text widget with an error message. Widget get activityWidget { if (currentActivity == null) { // return sizedbox with height of 80 @@ -325,15 +326,20 @@ class MessagePracticeActivityCardState extends State { } } + String? get userMessage { + // if the user has finished all the activities to unlock the toolbar in this session + if (widget.overlayController.finishedActivitiesThisSession) { + return "Boom! Tools unlocked!"; + + // if we have no activities to show + } else if (!fetchingActivity && currentActivity == null) { + return L10n.of(context)!.noActivitiesFound; + } + return null; + } + @override Widget build(BuildContext context) { - String? userMessage; - if (widget.overlayController.finishedActivitiesThisSession) { - userMessage = "Boom! Tools unlocked!"; - } else if (!fetchingActivity && currentActivity == null) { - userMessage = L10n.of(context)!.noActivitiesFound; - } - if (userMessage != null) { return Center( child: Container( @@ -341,7 +347,7 @@ class MessagePracticeActivityCardState extends State { minHeight: 80, ), child: Text( - userMessage, + userMessage!, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -352,11 +358,10 @@ class MessagePracticeActivityCardState extends State { } return Stack( - alignment: Alignment.topCenter, + alignment: Alignment.center, children: [ // Main content const Positioned( - top: 40, child: PointsGainedAnimation(), ), Column( From 26752a9ba7a9ace08be6c5cdc8f58119dfb7251b Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Thu, 26 Sep 2024 16:32:12 -0400 Subject: [PATCH 7/7] change back to regular .env --- lib/main.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 36add47dc..6be6edc91 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env.local_choreo"); + await dotenv.load(fileName: ".env"); } catch (e) { Logs().e('Failed to load .env file', e); }