From 6c792e3f23a5bf2fc344a2c11cff75d84b59cf81 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Wed, 18 Sep 2024 16:04:41 -0400 Subject: [PATCH 01/25] 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 89678de541831f2db6ea99ee1237559fd6d91c60 Mon Sep 17 00:00:00 2001 From: WilsonLe Date: Mon, 23 Sep 2024 16:33:16 -0400 Subject: [PATCH 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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); } From 8bffe17455355dd9701449625c04a3cef2dcb113 Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Wed, 2 Oct 2024 14:58:29 -0400 Subject: [PATCH 07/25] passing practice model instead of activity? --- lib/main.dart | 2 +- lib/pages/chat/chat.dart | 19 +- .../choreographer/widgets/choice_array.dart | 36 +- .../controllers/my_analytics_controller.dart | 77 +--- ...actice_activity_generation_controller.dart | 18 + .../practice_activity_event.dart | 39 +- lib/pangea/models/pangea_token_model.dart | 13 +- .../message_activity_request.dart | 53 ++- .../practice_activity_record_model.dart | 75 ++-- .../chat/message_selection_overlay.dart | 30 +- lib/pangea/widgets/chat/message_toolbar.dart | 15 +- .../multiple_choice_activity.dart | 8 +- .../practice_activity_card.dart | 382 ++++++++++-------- 13 files changed, 433 insertions(+), 334 deletions(-) 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 b6256fc03..47998523c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -20,6 +20,7 @@ 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'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; @@ -652,15 +653,25 @@ class ChatController extends State // There's a listen in my_analytics_controller that decides when to auto-update // analytics based on when / how many messages the logged in user send. This // stream sends the data for newly sent messages. + final metadata = ConstructUseMetaData( + roomId: roomId, + timeStamp: DateTime.now(), + eventId: msgEventId, + ); + if (msgEventId != null) { pangeaController.myAnalytics.setState( AnalyticsStream( eventId: msgEventId, - eventType: EventTypes.Message, roomId: room.id, - originalSent: originalSent, - tokensSent: tokensSent, - choreo: choreo, + constructs: [ + ...(choreo!.grammarConstructUses(metadata: metadata)), + ...(originalSent!.vocabUses( + choreo: choreo, + tokens: tokensSent!.tokens, + metadata: metadata, + )), + ], ), ); } diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index 2bf48b08a..ff13da78f 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -67,25 +67,23 @@ class ChoicesArrayState extends State { : Wrap( alignment: WrapAlignment.center, children: widget.choices! - .mapIndexed( - (index, entry) => ChoiceItem( - theme: theme, - onLongPress: - widget.isActive ? widget.onLongPress : null, - 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 == index, - ), - ) - .toList() ?? - [], + .mapIndexed( + (index, entry) => ChoiceItem( + theme: theme, + onLongPress: widget.isActive ? widget.onLongPress : null, + 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 == index, + ), + ) + .toList(), ); } } diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index a856f89db..cd6991864 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,20 +1,14 @@ import 'dart:async'; import 'package:fluffychat/pangea/constants/local.key.dart'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/base_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_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/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; @@ -29,7 +23,7 @@ class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; CachedStreamController analyticsUpdateStream = CachedStreamController(); - StreamSubscription? _messageSendSubscription; + StreamSubscription? _analyticsStream; Timer? _updateTimer; Client get _client => _pangeaController.matrixState.client; @@ -60,7 +54,7 @@ class MyAnalyticsController extends BaseController { void initialize() { // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user - _messageSendSubscription ??= + _analyticsStream ??= stateStream.listen((data) => _onNewAnalyticsData(data)); _refreshAnalyticsIfOutdated(); @@ -72,8 +66,8 @@ class MyAnalyticsController extends BaseController { _updateTimer?.cancel(); lastUpdated = null; lastUpdatedCompleter = Completer(); - _messageSendSubscription?.cancel(); - _messageSendSubscription = null; + _analyticsStream?.cancel(); + _analyticsStream = null; _refreshAnalyticsIfOutdated(); clearMessagesSinceUpdate(); } @@ -109,34 +103,9 @@ class MyAnalyticsController extends BaseController { /// Given the data from a newly sent message, format and cache /// the message's construct data locally and reset the update timer void _onNewAnalyticsData(AnalyticsStream data) { - // convert that data into construct uses and add it to the cache - final metadata = ConstructUseMetaData( - roomId: data.roomId, - eventId: data.eventId, - timeStamp: DateTime.now(), - ); - final List constructs = _getDraftUses(data.roomId); - if (data.eventType == EventTypes.Message) { - constructs.addAll([ - ...(data.choreo!.grammarConstructUses(metadata: metadata)), - ...(data.originalSent!.vocabUses( - choreo: data.choreo, - tokens: data.tokensSent!.tokens, - metadata: metadata, - )), - ]); - } else if (data.eventType == PangeaEventTypes.activityRecord && - data.practiceActivity != null) { - final activityConstructs = data.recordModel!.uses( - data.practiceActivity!, - metadata: metadata, - ); - constructs.addAll(activityConstructs); - } else { - throw PangeaWarningError("Invalid event type for analytics stream"); - } + constructs.addAll(data.constructs); final String eventID = data.eventId; final String roomID = data.roomId; @@ -342,43 +311,13 @@ 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; + final List constructs; 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, - ); - } + required this.constructs, + }); } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index fe2ebb388..bdba9a611 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -119,10 +119,28 @@ class PracticeGenerationController { requestModel: req, ); + // if the server points to an existing event, return that event + if (res.existingActivityEventId != null) { + debugPrint( + 'Existing activity event found: ${res.existingActivityEventId}', + ); + final Event? existingEvent = + await event.room.getEventById(res.existingActivityEventId!); + if (existingEvent != null) { + return PracticeActivityEvent( + event: existingEvent, + timeline: event.timeline, + ); + } + } + if (res.activity == null) { + debugPrint('No activity generated'); return null; } + debugPrint('Activity generated: ${res.activity!.toJson()}'); + final Future eventFuture = _sendAndPackageEvent(res.activity!, event); diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index 6616a8c06..2dab65618 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.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 'package:matrix/matrix.dart'; @@ -62,24 +63,34 @@ class PracticeActivityEvent { /// Completion record assosiated with this activity /// for the logged in user, null if there is none - PracticeActivityRecordEvent? get userRecord { - final List records = allRecords - .where( - (recordEvent) => - recordEvent.event.senderId == - recordEvent.event.room.client.userID, - ) - .toList(); - if (records.length > 1) { - debugPrint("There should only be one record per user per activity"); - debugger(when: kDebugMode); - } - return records.firstOrNull; + List get allUserRecords => allRecords + .where( + (recordEvent) => + recordEvent.event.senderId == recordEvent.event.room.client.userID, + ) + .toList(); + + /// Get the most recent user record for this activity + PracticeActivityRecordEvent? get latestUserRecord { + final List userRecords = allUserRecords; + if (userRecords.isEmpty) return null; + return userRecords.reduce( + (a, b) => a.event.originServerTs.isAfter(b.event.originServerTs) ? a : b, + ); } + DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs; + String get parentMessageId => event.relationshipEventId!; /// Checks if there are any user records in the list for this activity, /// and, if so, then the activity is complete - bool get isComplete => userRecord != null; + bool get isComplete => latestUserRecord != null; + + ExistingActivityMetaData get activityRequestMetaData => + ExistingActivityMetaData( + activityEventId: event.eventId, + tgtConstructs: practiceActivity.tgtConstructs, + activityType: practiceActivity.activityType, + ); } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index e0697d859..b8a73b65a 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -34,13 +34,11 @@ class PangeaToken { int endTokenIndex = -1, ]) { if (endTokenIndex == -1) { - endTokenIndex = tokens.length - 1; + endTokenIndex = tokens.length; } final List subset = - tokens.whereIndexed((int index, PangeaToken token) { - return index >= startTokenIndex && index <= endTokenIndex; - }).toList(); + tokens.sublist(startTokenIndex, endTokenIndex); if (subset.isEmpty) { debugger(when: kDebugMode); @@ -51,10 +49,11 @@ class PangeaToken { return subset.first.text.content; } - String reconstruction = subset.first.text.content; - for (int i = 1; i < subset.length - 1; i++) { + String reconstruction = ""; + for (int i = 0; i < subset.length; i++) { int whitespace = subset[i].text.offset - - (subset[i - 1].text.offset + subset[i - 1].text.length); + (i > 0 ? (subset[i - 1].text.offset + subset[i - 1].text.length) : 0); + if (whitespace < 0) { debugger(when: kDebugMode); whitespace = 0; 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 671df8ec6..059f14d20 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -1,11 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; class ConstructWithXP { final ConstructIdentifier id; int xp; - final DateTime? lastUsed; + DateTime? lastUsed; ConstructWithXP({ required this.id, @@ -94,13 +95,52 @@ class TokenWithXP { } } +class ExistingActivityMetaData { + final String activityEventId; + final List tgtConstructs; + final ActivityTypeEnum activityType; + + ExistingActivityMetaData({ + required this.activityEventId, + required this.tgtConstructs, + required this.activityType, + }); + + factory ExistingActivityMetaData.fromJson(Map json) { + return ExistingActivityMetaData( + activityEventId: json['activity_event_id'] as String, + tgtConstructs: (json['tgt_constructs'] as List) + .map((e) => ConstructIdentifier.fromJson(e as Map)) + .toList(), + activityType: ActivityTypeEnum.values.firstWhere( + (element) => + element.string == json['activity_type'] as String || + element.string.split('.').last == json['activity_type'] as String, + ), + ); + } + + Map toJson() { + return { + 'activity_event_id': activityEventId, + 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), + 'activity_type': activityType.string, + }; + } +} + class MessageActivityRequest { final String userL1; final String userL2; final String messageText; + + /// tokens with their associated constructs and xp final List tokensWithXP; + /// make the server aware of existing activities for potential reuse + final List existingActivities; + final String messageId; MessageActivityRequest({ @@ -109,6 +149,7 @@ class MessageActivityRequest { required this.messageText, required this.tokensWithXP, required this.messageId, + required this.existingActivities, }); factory MessageActivityRequest.fromJson(Map json) { @@ -120,6 +161,11 @@ class MessageActivityRequest { .map((e) => TokenWithXP.fromJson(e as Map)) .toList(), messageId: json['message_id'] as String, + existingActivities: (json['existing_activities'] as List) + .map( + (e) => ExistingActivityMetaData.fromJson(e as Map), + ) + .toList(), ); } @@ -130,6 +176,7 @@ class MessageActivityRequest { 'message_text': messageText, 'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), 'message_id': messageId, + 'existing_activities': existingActivities.map((e) => e.toJson()).toList(), }; } @@ -152,10 +199,12 @@ class MessageActivityRequest { class MessageActivityResponse { final PracticeActivityModel? activity; final bool finished; + final String? existingActivityEventId; MessageActivityResponse({ required this.activity, required this.finished, + required this.existingActivityEventId, }); factory MessageActivityResponse.fromJson(Map json) { @@ -166,6 +215,7 @@ class MessageActivityResponse { ) : null, finished: json['finished'] as bool, + existingActivityEventId: json['existing_activity_event_id'] as String?, ); } @@ -173,6 +223,7 @@ class MessageActivityResponse { return { 'activity': activity?.toJson(), 'finished': finished, + 'existing_activity_event_id': existingActivityEventId, }; } } 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 90c30a17a..1db288973 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 @@ -5,12 +5,10 @@ import 'dart:developer'; 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/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; -import 'package:matrix/matrix.dart'; class PracticeActivityRecordModel { final String? question; @@ -57,12 +55,6 @@ class PracticeActivityRecordModel { return responses[responses.length - 1]; } - ConstructUseTypeEnum get useType => latestResponse?.score != null - ? (latestResponse!.score > 0 - ? ConstructUseTypeEnum.corPA - : ConstructUseTypeEnum.incPA) - : ConstructUseTypeEnum.unk; - bool hasTextResponse(String text) { return responses.any((element) => element.text == text); } @@ -91,50 +83,50 @@ class PracticeActivityRecordModel { /// Returns a list of [OneConstructUse] objects representing the uses of the practice activity. /// /// The [practiceActivity] parameter is the parent event, representing the activity itself. - /// The [event] parameter is the record event, if available. /// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available. /// - /// If [event] and [metadata] are both null, an empty list is returned. - /// - /// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct. + /// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct and useType. List uses( - PracticeActivityEvent practiceActivity, { - Event? event, - ConstructUseMetaData? metadata, - }) { + PracticeActivityModel practiceActivity, + ConstructUseMetaData metadata, + ) { try { - if (event == null && metadata == null) { - debugger(when: kDebugMode); - return []; - } - final List uses = []; - final List constructIds = - practiceActivity.practiceActivity.tgtConstructs; - for (final construct in constructIds) { - uses.add( - OneConstructUse( - lemma: construct.lemma, - constructType: construct.type, - useType: useType, - //TODO - find form of construct within the message - //this is related to the feature of highlighting the target construct in the message - form: construct.lemma, - metadata: ConstructUseMetaData( - roomId: event?.roomId ?? metadata!.roomId, - eventId: practiceActivity.parentMessageId, - timeStamp: event?.originServerTs ?? metadata!.timeStamp, + final uniqueResponses = responses.toSet(); + + final List useTypes = + uniqueResponses.map((response) => response.useType).toList(); + + for (final construct in practiceActivity.tgtConstructs) { + for (final useType in useTypes) { + uses.add( + OneConstructUse( + lemma: construct.lemma, + constructType: construct.type, + useType: useType, + //TODO - find form of construct within the message + //this is related to the feature of highlighting the target construct in the message + form: construct.lemma, + metadata: metadata, ), - ), - ); + ); + } } return uses; } catch (e, s) { debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: s, data: event?.toJson()); - rethrow; + ErrorHandler.logError( + e: e, + s: s, + data: { + 'recordModel': toJson(), + 'practiceActivity': practiceActivity, + 'metadata': metadata, + }, + ); + return []; } } @@ -172,6 +164,9 @@ class ActivityRecordResponse { required this.timestamp, }); + ConstructUseTypeEnum get useType => + score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + factory ActivityRecordResponse.fromJson(Map json) { return ActivityRecordResponse( text: json['text'] as String?, diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index e1774ac98..6118d5fd9 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -58,7 +58,11 @@ class MessageOverlayController extends State /// 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; + int completedThisSession = 0; + + bool get finishedActivitiesThisSession => completedThisSession >= needed; + + late int activitiesLeftToComplete = needed; @override void initState() { @@ -68,17 +72,29 @@ class MessageOverlayController extends State duration: FluffyThemes.animationDuration, ); + activitiesLeftToComplete = + needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; + setInitialToolbarMode(); } - int get activitiesLeftToComplete => - needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; - bool get isPracticeComplete => activitiesLeftToComplete <= 0; + /// When an activity is completed, we need to update the state + /// and check if the toolbar should be unlocked + void onActivityFinish() { + if (!mounted) return; + completedThisSession += 1; + activitiesLeftToComplete -= 1; + clearSelection(); + setState(() {}); + } + /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { + debugPrint('Exiting practice flow'); + clearSelection(); needed = 0; setInitialToolbarMode(); setState(() {}); @@ -129,6 +145,10 @@ class MessageOverlayController extends State void onClickOverlayMessageToken( PangeaToken token, ) { + if (toolbarMode == MessageMode.practiceActivity) { + return; + } + // if there's no selected span, then select the token if (_selectedSpan == null) { _selectedSpan = token.text; @@ -150,7 +170,7 @@ class MessageOverlayController extends State setState(() {}); } - void onNewActivity(PracticeActivityModel activity) { + void setSelectedSpan(PracticeActivityModel activity) { final RelevantSpanDisplayDetails? span = activity.multipleChoice?.spanDisplayDetails; diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index deef7f232..23db604fd 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -250,7 +250,6 @@ class ToolbarButtonsState extends State { .toList(); static const double iconWidth = 36.0; - double get progressWidth => widget.width / overlayController.needed; MessageOverlayController get overlayController => widget.messageToolbarController.widget.overLayController; @@ -263,6 +262,8 @@ class ToolbarButtonsState extends State { @override Widget build(BuildContext context) { + final double barWidth = widget.width - iconWidth; + if (widget .messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) { return const SizedBox(); @@ -286,11 +287,13 @@ class ToolbarButtonsState extends State { AnimatedContainer( duration: FluffyThemes.animationDuration, height: 12, - width: min( - widget.width, - progressWidth * - pangeaMessageEvent.numberOfActivitiesCompleted, - ), + width: overlayController.isPracticeComplete + ? barWidth + : min( + barWidth, + (barWidth / 3) * + pangeaMessageEvent.numberOfActivitiesCompleted, + ), color: AppConfig.success, margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), ), diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 65b6f0704..0c426b8ac 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -29,7 +29,7 @@ class MultipleChoiceActivityState extends State { widget.practiceCardController.currentCompletionRecord; bool get isSubmitted => - widget.currentActivity?.userRecord?.record.latestResponse != null; + widget.currentActivity?.latestUserRecord?.record.latestResponse != null; @override void initState() { @@ -52,7 +52,7 @@ class MultipleChoiceActivityState extends State { /// Otherwise, it sets the current model to the user record's record and /// determines the selected choice index. void setCompletionRecord() { - if (widget.currentActivity?.userRecord?.record == null) { + if (widget.currentActivity?.latestUserRecord?.record == null) { widget.practiceCardController.setCompletionRecord( PracticeActivityRecordModel( question: @@ -61,8 +61,8 @@ class MultipleChoiceActivityState extends State { ); selectedChoiceIndex = null; } else { - widget.practiceCardController - .setCompletionRecord(widget.currentActivity!.userRecord!.record); + widget.practiceCardController.setCompletionRecord( + widget.currentActivity!.latestUserRecord!.record); selectedChoiceIndex = widget .currentActivity?.practiceActivity.multipleChoice! .choiceIndex(currentRecordModel!.latestResponse!.text!); diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index d118ca684..7574290c1 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,7 +1,6 @@ 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'; @@ -9,8 +8,8 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar 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/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -21,7 +20,6 @@ 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 associated with a message, @@ -46,18 +44,14 @@ class MessagePracticeActivityCardState extends State { PracticeActivityRecordModel? currentCompletionRecord; bool fetchingActivity = false; - List targetTokens = []; + TargetTokensController targetTokensController = TargetTokensController(); List get practiceActivities => widget.pangeaMessageEvent.practiceActivities; - int get practiceEventIndex => practiceActivities.indexWhere( - (activity) => activity.event.eventId == currentActivity?.event.eventId, - ); - - /// 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? + // Used to show an animation when the user completes an activity + // while simultaneously fetching a new activity and not showing the loading spinner + // until the appropriate time has passed to 'savor the joy' Duration appropriateTimeForJoy = const Duration(milliseconds: 500); bool savoringTheJoy = false; Timer? joyTimer; @@ -68,150 +62,100 @@ class MessagePracticeActivityCardState extends State { initialize(); } - void updateFetchingActivity(bool value) { + @override + void dispose() { + joyTimer?.cancel(); + super.dispose(); + } + + void _updateFetchingActivity(bool value) { if (fetchingActivity == value) return; setState(() => fetchingActivity = value); } - /// Get an activity to display. - /// Show an uncompleted activity if there is one. + /// Set target tokens. + /// Get an existing 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 = + _fetchExistingIncompleteActivity() ?? await _fetchNewActivity(); currentActivity == null ? widget.overlayController.exitPracticeFlow() : widget.overlayController - .onNewActivity(currentActivity!.practiceActivity); + .setSelectedSpan(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(); - - final PracticeActivityEvent? existingActivity = - incompleteActivities.isNotEmpty ? incompleteActivities.first : null; - - return existingActivity != null && - existingActivity.practiceActivity != - currentActivity?.practiceActivity - ? existingActivity - : null; - } - - Future _fetchNewActivity() async { - updateFetchingActivity(true); - - if (targetTokens.isEmpty || - !pangeaController.languageController.languagesSet) { - debugger(when: kDebugMode); - updateFetchingActivity(false); + PracticeActivityEvent? _fetchExistingIncompleteActivity() { + if (practiceActivities.isEmpty) { 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, - ); + final List incompleteActivities = + practiceActivities.where((element) => !element.isComplete).toList(); - /// 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; + // TODO - maybe check the user's xp for the tgtConstructs and decide if its relevant for them + // however, maybe we'd like to go ahead and give them the activity to get some data on our xp? + return incompleteActivities.firstOrNull; } - /// 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, + Future _fetchNewActivity() async { + try { + debugPrint('Fetching new activity'); + + _updateFetchingActivity(true); + + // target tokens can be empty if activities have been completed for each + // it's set on initialization and then removed when each activity is completed + if (!pangeaController.languageController.languagesSet) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + if (!mounted) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + final PracticeActivityEvent? ourNewActivity = await pangeaController + .practiceGenerationController + .getPracticeActivity( + MessageActivityRequest( + userL1: pangeaController.languageController.userL1!.langCode, + userL2: pangeaController.languageController.userL2!.langCode, + messageText: representation!.text, + tokensWithXP: await targetTokensController.targetTokens( + context, + widget.pangeaMessageEvent, + ), + messageId: widget.pangeaMessageEvent.eventId, + existingActivities: practiceActivities + .map((activity) => activity.activityRequestMetaData) + .toList(), + ), + widget.pangeaMessageEvent, ); - 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; + _updateFetchingActivity(false); - if (representation == null) { + return ourNewActivity; + } catch (e, s) { debugger(when: kDebugMode); - return []; + ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to get new activity', + data: { + 'activity': currentActivity, + 'record': currentCompletionRecord, + }, + ); + return null; } - 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) { @@ -219,7 +163,7 @@ class MessagePracticeActivityCardState extends State { } /// future that simply waits for the appropriate time to savor the joy - Future savorTheJoy() async { + Future _savorTheJoy() async { joyTimer?.cancel(); if (savoringTheJoy) return; savoringTheJoy = true; @@ -229,10 +173,10 @@ class MessagePracticeActivityCardState extends State { }); } - /// Sends the current record model and activity to the server. - /// If either the currentRecordModel or currentActivity is null, the method returns early. - /// 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. + /// Called when the user finishes an activity. + /// Saves the completion record and sends it to the server. + /// Fetches a new activity if there are any left to complete. + /// Exits the practice flow if there are no more activities. void onActivityFinish() async { try { if (currentCompletionRecord == null || currentActivity == null) { @@ -241,45 +185,63 @@ class MessagePracticeActivityCardState extends State { } // start joy timer - savorTheJoy(); + _savorTheJoy(); - // 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!, + final uses = currentCompletionRecord!.uses( + currentActivity!.practiceActivity, + ConstructUseMetaData( + roomId: widget.pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), ), ); - if (!widget.overlayController.finishedActivitiesThisSession) { - currentActivity = await _fetchNewActivity(); + // update the target tokens with the new construct uses + targetTokensController.updateTokensWithConstructs( + uses, + context, + widget.pangeaMessageEvent, + ); - currentActivity == null - ? widget.overlayController.exitPracticeFlow() - : widget.overlayController - .onNewActivity(currentActivity!.practiceActivity); - } else { - updateFetchingActivity(false); - widget.overlayController.setState(() {}); - } + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + // note - this maybe should be the activity event id + eventId: widget.pangeaMessageEvent.eventId, + roomId: widget.pangeaMessageEvent.room.id, + constructs: uses, + ), + ); + + // save the record without awaiting to avoid blocking the UI + // send a copy of the activity record to make sure its not overwritten by + // the new activity + MatrixState.pangeaController.activityRecordController + .send(currentCompletionRecord!, currentActivity!) + .catchError( + (e, s) => ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to save record', + data: { + 'record': currentCompletionRecord?.toJson(), + 'activity': currentActivity?.practiceActivity.toJson(), + }, + ), + ); + + widget.overlayController.onActivityFinish(); + + currentActivity = await _fetchNewActivity(); + + currentActivity == null + ? widget.overlayController.exitPracticeFlow() + : widget.overlayController + .setSelectedSpan(currentActivity!.practiceActivity); } catch (e, s) { debugger(when: kDebugMode); ErrorHandler.logError( e: e, s: s, - m: 'Failed to send record for activity', + m: 'Failed to get new activity', data: { 'activity': currentActivity, 'record': currentCompletionRecord, @@ -340,6 +302,9 @@ class MessagePracticeActivityCardState extends State { @override Widget build(BuildContext context) { + debugPrint( + 'Building practice activity card with ${widget.overlayController.activitiesLeftToComplete} activities left to complete', + ); if (userMessage != null) { return Center( child: Container( @@ -385,3 +350,92 @@ class MessagePracticeActivityCardState extends State { ); } } + +/// Seperated out the target tokens from the practice activity card +/// in order to control the state of the target tokens +class TargetTokensController { + List? _targetTokens; + + TargetTokensController(); + + /// From the tokens in the message, do a preliminary filtering of which to target + /// Then get the construct uses for those tokens + Future> targetTokens( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (_targetTokens != null) { + return _targetTokens!; + } + + _targetTokens = await _initialize(context, pangeaMessageEvent); + + await updateTokensWithConstructs( + MatrixState.pangeaController.analytics.analyticsStream.value ?? [], + context, + pangeaMessageEvent, + ); + + return _targetTokens!; + } + + Future> _initialize( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (!context.mounted) { + ErrorHandler.logError( + m: 'getTargetTokens called when not mounted', + s: StackTrace.current, + ); + return _targetTokens = []; + } + + final tokens = await pangeaMessageEvent + .representationByLanguage(pangeaMessageEvent.messageDisplayLangCode) + ?.tokensGlobal(context); + + if (tokens == null || tokens.isEmpty) { + debugger(when: kDebugMode); + return _targetTokens = []; + } + + _targetTokens = []; + 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; + } + + _targetTokens!.add(tokens[i].emptyTokenWithXP); + } + + return _targetTokens!; + } + + Future updateTokensWithConstructs( + List constructUses, + context, + pangeaMessageEvent, + ) async { + final ConstructListModel constructList = ConstructListModel( + uses: constructUses, + type: null, + ); + + _targetTokens ??= await _initialize(context, pangeaMessageEvent); + + for (final token in _targetTokens!) { + for (final construct in token.constructs) { + final constructUseModel = constructList.getConstructUses( + construct.id.lemma, + construct.id.type, + ); + if (constructUseModel != null) { + construct.xp = constructUseModel.points; + construct.lastUsed = constructUseModel.lastUsed; + } + } + } + } +} From f9ad45d20359c7a8972e4a6c29ded6b8e0db1b93 Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Wed, 2 Oct 2024 17:40:16 -0400 Subject: [PATCH 08/25] intelligently choosing tokens and passing all their info --- lib/main.dart | 2 +- ...actice_activity_generation_controller.dart | 7 +- .../analytics/construct_list_model.dart | 4 +- .../message_activity_request.dart | 3 +- .../practice_activity_model.dart | 15 ++ .../multiple_choice_activity.dart | 50 +++--- .../practice_activity_card.dart | 160 +++++++++--------- 7 files changed, 132 insertions(+), 109 deletions(-) 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); } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index bdba9a611..bbe961ff8 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -121,11 +121,12 @@ class PracticeGenerationController { // if the server points to an existing event, return that event if (res.existingActivityEventId != null) { - debugPrint( - 'Existing activity event found: ${res.existingActivityEventId}', - ); final Event? existingEvent = await event.room.getEventById(res.existingActivityEventId!); + + debugPrint( + 'Existing activity event found: ${existingEvent?.content}', + ); if (existingEvent != null) { return PracticeActivityEvent( event: existingEvent, diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 723f89423..d73b5060a 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -21,7 +21,7 @@ class ConstructListModel { }) : _uses = uses; List get uses => - _uses.where((use) => use.constructType == type).toList(); + _uses.where((use) => use.constructType == type || type == null).toList(); /// All unique lemmas used in the construct events List get lemmas => constructList.map((e) => e.lemma).toSet().toList(); @@ -38,7 +38,7 @@ class ConstructListModel { _constructMap = lemmaToUses.map( (key, value) => MapEntry( - key + value.first.constructType.string, + key, ConstructUses( uses: value, constructType: value.first.constructType, 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 059f14d20..9dabc879d 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -27,11 +27,12 @@ class ConstructWithXP { } Map toJson() { - return { + final json = { 'construct_id': id.toJson(), 'xp': xp, 'last_used': lastUsed?.toIso8601String(), }; + return json; } } 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 0511d8055..4e93cb279 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -253,6 +253,21 @@ class PracticeActivityModel { this.freeResponse, }); + String get question { + switch (activityType) { + case ActivityTypeEnum.multipleChoice: + return multipleChoice!.question; + case ActivityTypeEnum.listening: + return listening!.text; + case ActivityTypeEnum.speaking: + return speaking!.text; + case ActivityTypeEnum.freeResponse: + return freeResponse!.question; + default: + return ''; + } + } + factory PracticeActivityModel.fromJson(Map json) { return PracticeActivityModel( tgtConstructs: (json['tgt_constructs'] as List) diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 0c426b8ac..16ddb164a 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -1,6 +1,5 @@ import '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'; @@ -28,21 +27,22 @@ class MultipleChoiceActivityState extends State { PracticeActivityRecordModel? get currentRecordModel => widget.practiceCardController.currentCompletionRecord; - bool get isSubmitted => - widget.currentActivity?.latestUserRecord?.record.latestResponse != null; + // bool get isSubmitted => + // widget.currentActivity?.latestUserRecord?.record.latestResponse != null; @override void initState() { super.initState(); - setCompletionRecord(); + // setCompletionRecord(); } @override void didUpdateWidget(covariant MultipleChoiceActivity oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.currentActivity?.event.eventId != - widget.currentActivity?.event.eventId) { - setCompletionRecord(); + if (widget.practiceCardController.currentCompletionRecord?.responses + .isEmpty ?? + false) { + selectedChoiceIndex = null; } } @@ -52,21 +52,21 @@ class MultipleChoiceActivityState extends State { /// Otherwise, it sets the current model to the user record's record and /// determines the selected choice index. void setCompletionRecord() { - if (widget.currentActivity?.latestUserRecord?.record == null) { - widget.practiceCardController.setCompletionRecord( - PracticeActivityRecordModel( - question: - widget.currentActivity?.practiceActivity.multipleChoice!.question, - ), - ); - selectedChoiceIndex = null; - } else { - widget.practiceCardController.setCompletionRecord( - widget.currentActivity!.latestUserRecord!.record); - selectedChoiceIndex = widget - .currentActivity?.practiceActivity.multipleChoice! - .choiceIndex(currentRecordModel!.latestResponse!.text!); - } + // if (widget.currentActivity?.latestUserRecord?.record == null) { + widget.practiceCardController.setCompletionRecord( + PracticeActivityRecordModel( + question: + widget.currentActivity?.practiceActivity.multipleChoice!.question, + ), + ); + selectedChoiceIndex = null; + // } else { + // widget.practiceCardController.setCompletionRecord( + // widget.currentActivity!.latestUserRecord!.record); + // selectedChoiceIndex = widget + // .currentActivity?.practiceActivity.multipleChoice! + // .choiceIndex(currentRecordModel!.latestResponse!.text!); + // } setState(() {}); } @@ -79,8 +79,8 @@ class MultipleChoiceActivityState extends State { .currentActivity!.practiceActivity.multipleChoice! .isCorrect(value, index); - final ConstructUseTypeEnum useType = - isCorrect ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + // final ConstructUseTypeEnum useType = + // isCorrect ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; currentRecordModel?.addResponse( text: value, @@ -146,7 +146,7 @@ class MultipleChoiceActivityState extends State { ), ) .toList(), - isActive: !isSubmitted, + isActive: true, ), ], ), diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 7574290c1..253b21f47 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -73,17 +73,27 @@ class MessagePracticeActivityCardState extends State { setState(() => fetchingActivity = value); } - /// Set target tokens. + void _setPracticeActivity(PracticeActivityEvent? activity) { + if (activity == null) { + widget.overlayController.exitPracticeFlow(); + return; + } + + currentActivity = activity; + + currentCompletionRecord = PracticeActivityRecordModel( + question: activity.practiceActivity.question, + ); + + widget.overlayController.setSelectedSpan(currentActivity!.practiceActivity); + } + /// Get an existing activity if there is one. /// If not, get a new activity from the server. Future initialize() async { - currentActivity = - _fetchExistingIncompleteActivity() ?? await _fetchNewActivity(); - - currentActivity == null - ? widget.overlayController.exitPracticeFlow() - : widget.overlayController - .setSelectedSpan(currentActivity!.practiceActivity); + _setPracticeActivity( + _fetchExistingIncompleteActivity() ?? await _fetchNewActivity(), + ); } // if the user did the activity before but awhile ago and we don't have any @@ -178,77 +188,73 @@ class MessagePracticeActivityCardState extends State { /// Fetches a new activity if there are any left to complete. /// Exits the practice flow if there are no more activities. void onActivityFinish() async { - try { - if (currentCompletionRecord == null || currentActivity == null) { - debugger(when: kDebugMode); - return; - } - - // start joy timer - _savorTheJoy(); - - final uses = currentCompletionRecord!.uses( - currentActivity!.practiceActivity, - ConstructUseMetaData( - roomId: widget.pangeaMessageEvent.room.id, - timeStamp: DateTime.now(), - ), - ); - - // update the target tokens with the new construct uses - targetTokensController.updateTokensWithConstructs( - uses, - context, - widget.pangeaMessageEvent, - ); - - MatrixState.pangeaController.myAnalytics.setState( - AnalyticsStream( - // note - this maybe should be the activity event id - eventId: widget.pangeaMessageEvent.eventId, - roomId: widget.pangeaMessageEvent.room.id, - constructs: uses, - ), - ); - - // save the record without awaiting to avoid blocking the UI - // send a copy of the activity record to make sure its not overwritten by - // the new activity - MatrixState.pangeaController.activityRecordController - .send(currentCompletionRecord!, currentActivity!) - .catchError( - (e, s) => ErrorHandler.logError( - e: e, - s: s, - m: 'Failed to save record', - data: { - 'record': currentCompletionRecord?.toJson(), - 'activity': currentActivity?.practiceActivity.toJson(), - }, - ), - ); - - widget.overlayController.onActivityFinish(); - - currentActivity = await _fetchNewActivity(); - - currentActivity == null - ? widget.overlayController.exitPracticeFlow() - : widget.overlayController - .setSelectedSpan(currentActivity!.practiceActivity); - } catch (e, s) { + // try { + if (currentCompletionRecord == null || currentActivity == null) { debugger(when: kDebugMode); - ErrorHandler.logError( - e: e, - s: s, - m: 'Failed to get new activity', - data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, - }, - ); - widget.overlayController.exitPracticeFlow(); + return; } + + // start joy timer + _savorTheJoy(); + + final uses = currentCompletionRecord!.uses( + currentActivity!.practiceActivity, + ConstructUseMetaData( + roomId: widget.pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), + ), + ); + + // update the target tokens with the new construct uses + await targetTokensController.updateTokensWithConstructs( + uses, + context, + widget.pangeaMessageEvent, + ); + + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + // note - this maybe should be the activity event id + eventId: widget.pangeaMessageEvent.eventId, + roomId: widget.pangeaMessageEvent.room.id, + constructs: uses, + ), + ); + + // save the record without awaiting to avoid blocking the UI + // send a copy of the activity record to make sure its not overwritten by + // the new activity + MatrixState.pangeaController.activityRecordController + .send(currentCompletionRecord!, currentActivity!) + .catchError( + (e, s) => ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to save record', + data: { + 'record': currentCompletionRecord?.toJson(), + 'activity': currentActivity?.practiceActivity.toJson(), + }, + ), + ); + + widget.overlayController.onActivityFinish(); + + _setPracticeActivity(await _fetchNewActivity()); + + // } catch (e, s) { + // debugger(when: kDebugMode); + // ErrorHandler.logError( + // e: e, + // s: s, + // m: 'Failed to get new activity', + // data: { + // 'activity': currentActivity, + // 'record': currentCompletionRecord, + // }, + // ); + // widget.overlayController.exitPracticeFlow(); + // } } RepresentationEvent? get representation => From 08a7c74b4a61a6e2f32fd7257588f500156e4dbb Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 09:56:00 -0400 Subject: [PATCH 09/25] remove print statement (#703) --- .../widgets/practice_activity/practice_activity_card.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 253b21f47..822ca78d8 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -308,9 +308,6 @@ class MessagePracticeActivityCardState extends State { @override Widget build(BuildContext context) { - debugPrint( - 'Building practice activity card with ${widget.overlayController.activitiesLeftToComplete} activities left to complete', - ); if (userMessage != null) { return Center( child: Container( From 51e8c4b7ced9a9b6abf3c00bf7ec292167c89c32 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:37:26 -0400 Subject: [PATCH 10/25] Toolbar practice (#704) * remove print statement * ending animation, savoring joy, properly adding xp in session --- assets/l10n/intl_en.arb | 2 +- assets/l10n/intl_es.arb | 1 + lib/main.dart | 2 +- .../practice_activity_record_model.dart | 69 +++++------- .../chat/message_selection_overlay.dart | 19 +--- .../multiple_choice_activity.dart | 64 ++++------- .../no_more_practice_card.dart | 97 ++++++++++++++++ .../practice_activity_card.dart | 105 +++++++----------- 8 files changed, 195 insertions(+), 164 deletions(-) create mode 100644 lib/pangea/widgets/practice_activity/no_more_practice_card.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 158495497..818c7e184 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4121,7 +4121,7 @@ "suggestToSpaceDesc": "Suggested sub spaces will appear in their main space's chat list", "practice": "Practice", "noLanguagesSet": "No languages set", - "noActivitiesFound": "No practice activities found for this message", + "noActivitiesFound": "You're all practiced for now! Come back later for more.", "hintTitle": "Hint:", "speechToTextBody": "See how well you did by looking at your Accuracy and Words Per Minute scores", "previous": "Previous", diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index cfdc42cb8..d371276b6 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4513,6 +4513,7 @@ "autoPlayTitle": "Reproducción automática de mensajes", "autoPlayDesc": "Cuando está activado, el audio de texto a voz de los mensajes se reproducirá automáticamente cuando se seleccione.", "presenceStyle": "Presencia:", + "noActivitiesFound": "¡Ya has practicado por ahora! Vuelve más tarde para ver más.", "presencesToggle": "Mostrar mensajes de estado de otros usuarios", "writeAMessageFlag": "Escribe un mensaje en {l1flag} o {l2flag}", "@writeAMessageFlag": { 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/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart index 1db288973..acac979d7 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 @@ -7,7 +7,6 @@ import 'dart:developer'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; class PracticeActivityRecordModel { @@ -85,50 +84,17 @@ class PracticeActivityRecordModel { /// The [practiceActivity] parameter is the parent event, representing the activity itself. /// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available. /// - /// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct and useType. - List uses( + /// The method iterates over the [responses] to get [OneConstructUse] objects for each + List usesForAllResponses( PracticeActivityModel practiceActivity, ConstructUseMetaData metadata, - ) { - try { - final List uses = []; - - final uniqueResponses = responses.toSet(); - - final List useTypes = - uniqueResponses.map((response) => response.useType).toList(); - - for (final construct in practiceActivity.tgtConstructs) { - for (final useType in useTypes) { - uses.add( - OneConstructUse( - lemma: construct.lemma, - constructType: construct.type, - useType: useType, - //TODO - find form of construct within the message - //this is related to the feature of highlighting the target construct in the message - form: construct.lemma, - metadata: metadata, - ), - ); - } - } - - return uses; - } catch (e, s) { - debugger(when: kDebugMode); - ErrorHandler.logError( - e: e, - s: s, - data: { - 'recordModel': toJson(), - 'practiceActivity': practiceActivity, - 'metadata': metadata, - }, - ); - return []; - } - } + ) => + responses + .toSet() + .expand( + (response) => response.toUses(practiceActivity, metadata), + ) + .toList(); @override bool operator ==(Object other) { @@ -164,9 +130,26 @@ class ActivityRecordResponse { required this.timestamp, }); + //TODO - differentiate into different activity types ConstructUseTypeEnum get useType => score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + // for each target construct create a OneConstructUse object + List toUses( + PracticeActivityModel practiceActivity, + ConstructUseMetaData metadata, + ) => + practiceActivity.tgtConstructs + .map( + (construct) => OneConstructUse( + lemma: construct.lemma, + constructType: construct.type, + useType: useType, + metadata: metadata, + ), + ) + .toList(); + factory ActivityRecordResponse.fromJson(Map json) { return ActivityRecordResponse( text: json['text'] as String?, diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 6118d5fd9..d3db75e6e 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -53,16 +53,9 @@ class MessageOverlayController extends State /// 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; + static const int neededActivities = 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 - int completedThisSession = 0; - - bool get finishedActivitiesThisSession => completedThisSession >= needed; - - late int activitiesLeftToComplete = needed; + int activitiesLeftToComplete = neededActivities; @override void initState() { @@ -72,8 +65,8 @@ class MessageOverlayController extends State duration: FluffyThemes.animationDuration, ); - activitiesLeftToComplete = - needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; + activitiesLeftToComplete = activitiesLeftToComplete - + widget._pangeaMessageEvent.numberOfActivitiesCompleted; setInitialToolbarMode(); } @@ -84,7 +77,6 @@ class MessageOverlayController extends State /// and check if the toolbar should be unlocked void onActivityFinish() { if (!mounted) return; - completedThisSession += 1; activitiesLeftToComplete -= 1; clearSelection(); setState(() {}); @@ -95,8 +87,7 @@ class MessageOverlayController extends State void exitPracticeFlow() { debugPrint('Exiting practice flow'); clearSelection(); - needed = 0; - setInitialToolbarMode(); + activitiesLeftToComplete = 0; setState(() {}); } diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 16ddb164a..d3b57dc45 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -1,9 +1,14 @@ +import 'dart:developer'; + import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; +import 'package:fluffychat/pangea/controllers/my_analytics_controller.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'; 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'; /// The multiple choice activity view @@ -27,13 +32,9 @@ class MultipleChoiceActivityState extends State { PracticeActivityRecordModel? get currentRecordModel => widget.practiceCardController.currentCompletionRecord; - // bool get isSubmitted => - // widget.currentActivity?.latestUserRecord?.record.latestResponse != null; - @override void initState() { super.initState(); - // setCompletionRecord(); } @override @@ -42,34 +43,10 @@ class MultipleChoiceActivityState extends State { if (widget.practiceCardController.currentCompletionRecord?.responses .isEmpty ?? false) { - selectedChoiceIndex = null; + setState(() => selectedChoiceIndex = null); } } - /// Sets the completion record for the multiple choice activity. - /// If the user record is null, it creates a new record model with the question - /// from the current activity and sets the selected choice index to null. - /// Otherwise, it sets the current model to the user record's record and - /// determines the selected choice index. - void setCompletionRecord() { - // if (widget.currentActivity?.latestUserRecord?.record == null) { - widget.practiceCardController.setCompletionRecord( - PracticeActivityRecordModel( - question: - widget.currentActivity?.practiceActivity.multipleChoice!.question, - ), - ); - selectedChoiceIndex = null; - // } else { - // widget.practiceCardController.setCompletionRecord( - // widget.currentActivity!.latestUserRecord!.record); - // selectedChoiceIndex = widget - // .currentActivity?.practiceActivity.multipleChoice! - // .choiceIndex(currentRecordModel!.latestResponse!.text!); - // } - setState(() {}); - } - void updateChoice(String value, int index) { if (currentRecordModel?.hasTextResponse(value) ?? false) { return; @@ -79,22 +56,29 @@ class MultipleChoiceActivityState extends State { .currentActivity!.practiceActivity.multipleChoice! .isCorrect(value, index); - // final ConstructUseTypeEnum useType = - // isCorrect ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; - currentRecordModel?.addResponse( 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 (currentRecordModel == null || + currentRecordModel!.latestResponse == null) { + debugger(when: kDebugMode); + return; + } + + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + // note - this maybe should be the activity event id + eventId: + widget.practiceCardController.widget.pangeaMessageEvent.eventId, + roomId: widget.practiceCardController.widget.pangeaMessageEvent.room.id, + constructs: currentRecordModel!.latestResponse!.toUses( + widget.practiceCardController.currentActivity!.practiceActivity, + widget.practiceCardController.metadata, + ), + ), + ); // If the selected choice is correct, send the record and get the next activity if (widget.currentActivity!.practiceActivity.multipleChoice! diff --git a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart new file mode 100644 index 000000000..cdcacea35 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +class StarAnimationWidget extends StatefulWidget { + const StarAnimationWidget({super.key}); + + @override + _StarAnimationWidgetState createState() => _StarAnimationWidgetState(); +} + +class _StarAnimationWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacityAnimation; + late Animation _sizeAnimation; + + @override + void initState() { + super.initState(); + + // Initialize the AnimationController + _controller = AnimationController( + duration: const Duration(seconds: 1), // Duration of the animation + vsync: this, + )..repeat(reverse: true); // Repeat the animation in reverse + + // Define the opacity animation + _opacityAnimation = + Tween(begin: 0.8, end: 1.0).animate(_controller); + + // Define the size animation + _sizeAnimation = Tween(begin: 56.0, end: 60.0).animate(_controller); + } + + @override + void dispose() { + // Dispose of the controller to free resources + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + // Set constant height and width for the star container + height: 80.0, + width: 80.0, + child: Center( + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Opacity( + opacity: _opacityAnimation.value, + child: Icon( + Icons.star, + color: Colors.amber, + size: _sizeAnimation.value, + ), + ); + }, + ), + ), + ); + } +} + +class GamifiedTextWidget extends StatelessWidget { + final String userMessage; + + const GamifiedTextWidget({super.key, required this.userMessage}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, // Adjusts the size to fit children + children: [ + // Star animation above the text + const StarAnimationWidget(), + const SizedBox(height: 10), // Spacing between the star and text + Container( + constraints: const BoxConstraints( + minHeight: 80, + ), + child: Text( + userMessage, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, // Center-align the text + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 822ca78d8..0e8d4051a 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:developer'; -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'; @@ -16,6 +15,7 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -52,9 +52,8 @@ class MessagePracticeActivityCardState extends State { // Used to show an animation when the user completes an activity // while simultaneously fetching a new activity and not showing the loading spinner // until the appropriate time has passed to 'savor the joy' - Duration appropriateTimeForJoy = const Duration(milliseconds: 500); + Duration appropriateTimeForJoy = const Duration(milliseconds: 1000); bool savoringTheJoy = false; - Timer? joyTimer; @override void initState() { @@ -62,30 +61,28 @@ class MessagePracticeActivityCardState extends State { initialize(); } - @override - void dispose() { - joyTimer?.cancel(); - super.dispose(); - } - void _updateFetchingActivity(bool value) { if (fetchingActivity == value) return; setState(() => fetchingActivity = value); } void _setPracticeActivity(PracticeActivityEvent? activity) { + //set elsewhere but just in case + fetchingActivity = false; + + currentActivity = activity; + if (activity == null) { widget.overlayController.exitPracticeFlow(); return; } - currentActivity = activity; - + //make new completion record currentCompletionRecord = PracticeActivityRecordModel( question: activity.practiceActivity.question, ); - widget.overlayController.setSelectedSpan(currentActivity!.practiceActivity); + widget.overlayController.setSelectedSpan(activity.practiceActivity); } /// Get an existing activity if there is one. @@ -168,19 +165,26 @@ class MessagePracticeActivityCardState extends State { } } - void setCompletionRecord(PracticeActivityRecordModel? recordModel) { - currentCompletionRecord = recordModel; - } + ConstructUseMetaData get metadata => ConstructUseMetaData( + eventId: widget.pangeaMessageEvent.eventId, + roomId: widget.pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), + ); - /// future that simply waits for the appropriate time to savor the joy Future _savorTheJoy() async { - joyTimer?.cancel(); - if (savoringTheJoy) return; + if (savoringTheJoy) { + //should not happen + debugger(when: kDebugMode); + } savoringTheJoy = true; - joyTimer = Timer(appropriateTimeForJoy, () { - savoringTheJoy = false; - joyTimer?.cancel(); - }); + + debugPrint('Savoring the joy'); + + await Future.delayed(appropriateTimeForJoy); + + savoringTheJoy = false; + + debugPrint('Savoring the joy is over'); } /// Called when the user finishes an activity. @@ -194,33 +198,17 @@ class MessagePracticeActivityCardState extends State { return; } - // start joy timer - _savorTheJoy(); - - final uses = currentCompletionRecord!.uses( - currentActivity!.practiceActivity, - ConstructUseMetaData( - roomId: widget.pangeaMessageEvent.room.id, - timeStamp: DateTime.now(), - ), - ); - // update the target tokens with the new construct uses + // NOTE - multiple choice activity is handling adding these to analytics await targetTokensController.updateTokensWithConstructs( - uses, + currentCompletionRecord!.usesForAllResponses( + currentActivity!.practiceActivity, + metadata, + ), context, widget.pangeaMessageEvent, ); - MatrixState.pangeaController.myAnalytics.setState( - AnalyticsStream( - // note - this maybe should be the activity event id - eventId: widget.pangeaMessageEvent.eventId, - roomId: widget.pangeaMessageEvent.room.id, - constructs: uses, - ), - ); - // save the record without awaiting to avoid blocking the UI // send a copy of the activity record to make sure its not overwritten by // the new activity @@ -240,7 +228,12 @@ class MessagePracticeActivityCardState extends State { widget.overlayController.onActivityFinish(); - _setPracticeActivity(await _fetchNewActivity()); + final Iterable result = await Future.wait([ + _savorTheJoy(), + _fetchNewActivity(), + ]); + + _setPracticeActivity(result.last as PracticeActivityEvent?); // } catch (e, s) { // debugger(when: kDebugMode); @@ -295,12 +288,7 @@ 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) { + if (!fetchingActivity && currentActivity == null) { return L10n.of(context)!.noActivitiesFound; } return null; @@ -309,20 +297,7 @@ class MessagePracticeActivityCardState extends State { @override Widget build(BuildContext context) { if (userMessage != null) { - return Center( - child: Container( - constraints: const BoxConstraints( - minHeight: 80, - ), - child: Text( - userMessage!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ); + return GamifiedTextWidget(userMessage: userMessage!); } return Stack( @@ -435,7 +410,7 @@ class TargetTokensController { construct.id.type, ); if (constructUseModel != null) { - construct.xp = constructUseModel.points; + construct.xp += constructUseModel.points; construct.lastUsed = constructUseModel.lastUsed; } } From a0e0fb00ecb2fcc9a6d35d0e16bd43fc0f2a80b0 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:48:27 -0400 Subject: [PATCH 11/25] Toolbar practice (#705) * remove print statement * ending animation, savoring joy, properly adding xp in session * forgot to switch env again... From 51d9efabebb8544241030e911b26ca984d79c316 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:07:40 -0400 Subject: [PATCH 12/25] Toolbar practice (#706) * remove print statement * ending animation, savoring joy, properly adding xp in session * forgot to switch env again... * increment version number --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 8e5f9fcee..1c8287d72 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 1.21.4+3536 +version: 1.21.4+3537 environment: sdk: ">=3.0.0 <4.0.0" From 371d4f06d43ef6b9b6fc2b8be05b45844f368463 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 12:42:44 -0400 Subject: [PATCH 13/25] Update main.dart Fixing env definition... again.... --- 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 b8edf595ca110116f091fe42f81d75f4aa778110 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:19:31 -0400 Subject: [PATCH 14/25] Toolbar practice (#707) * remove print statement * ending animation, savoring joy, properly adding xp in session * forgot to switch env again... * increment version number * about to move toolbar buttons up to level of overlay controller * added ability to give feedback and get new activity --- assets/l10n/intl_en.arb | 7 +- assets/l10n/intl_es.arb | 3 +- lib/pages/chat/events/message_content.dart | 2 +- .../message_activity_request.dart | 41 ++- .../practice_activity_model.dart | 12 +- .../widgets/chat/message_audio_card.dart | 1 + .../chat/message_selection_overlay.dart | 40 ++- lib/pangea/widgets/chat/message_toolbar.dart | 255 ++----------- .../widgets/chat/message_toolbar_buttons.dart | 122 +++++++ .../chat/message_toolbar_selection_area.dart | 48 +++ .../chat/message_translation_card.dart | 2 + lib/pangea/widgets/igc/pangea_rich_text.dart | 2 +- lib/pangea/widgets/igc/word_data_card.dart | 117 +++--- .../no_more_practice_card.dart | 1 + .../practice_activity_card.dart | 339 +++++++++--------- .../target_tokens_controller.dart | 99 +++++ pubspec.lock | 36 +- 17 files changed, 654 insertions(+), 473 deletions(-) create mode 100644 lib/pangea/widgets/chat/message_toolbar_buttons.dart create mode 100644 lib/pangea/widgets/chat/message_toolbar_selection_area.dart create mode 100644 lib/pangea/widgets/practice_activity/target_tokens_controller.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 818c7e184..de1b2f8e7 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4121,7 +4121,7 @@ "suggestToSpaceDesc": "Suggested sub spaces will appear in their main space's chat list", "practice": "Practice", "noLanguagesSet": "No languages set", - "noActivitiesFound": "You're all practiced for now! Come back later for more.", + "noActivitiesFound": "That's enough on this for now! Come back later for more.", "hintTitle": "Hint:", "speechToTextBody": "See how well you did by looking at your Accuracy and Words Per Minute scores", "previous": "Previous", @@ -4229,5 +4229,8 @@ "grammar": "Grammar", "contactHasBeenInvitedToTheChat": "Contact has been invited to the chat", "inviteChat": "📨 Invite chat", - "chatName": "Chat name" + "chatName": "Chat name", + "reportContentIssueTitle": "Report content issue", + "feedback": "Your feedback (optional)", + "reportContentIssueDescription": "Sorry! AI can make personalized experiences but also may have issues. Please provide any feedback you have and we'll generate a new activity." } \ No newline at end of file diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index d371276b6..632ff11ee 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4737,5 +4737,6 @@ } }, "commandHint_googly": "Enviar unos ojos saltones", - "@commandHint_googly": {} + "@commandHint_googly": {}, + "reportContentIssue": "Problema de contenido" } \ No newline at end of file diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index eca0cfd5f..2cddb6f67 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -4,7 +4,7 @@ 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/message_toolbar_selection_area.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'; 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 9dabc879d..b8d44f41b 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -110,7 +110,8 @@ class ExistingActivityMetaData { factory ExistingActivityMetaData.fromJson(Map json) { return ExistingActivityMetaData( activityEventId: json['activity_event_id'] as String, - tgtConstructs: (json['tgt_constructs'] as List) + tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs']) + as List) .map((e) => ConstructIdentifier.fromJson(e as Map)) .toList(), activityType: ActivityTypeEnum.values.firstWhere( @@ -124,18 +125,47 @@ class ExistingActivityMetaData { Map toJson() { return { 'activity_event_id': activityEventId, - 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), + 'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(), 'activity_type': activityType.string, }; } } +// includes feedback text and the bad activity model +class ActivityQualityFeedback { + final String feedbackText; + final PracticeActivityModel badActivity; + + ActivityQualityFeedback({ + required this.feedbackText, + required this.badActivity, + }); + + factory ActivityQualityFeedback.fromJson(Map json) { + return ActivityQualityFeedback( + feedbackText: json['feedback_text'] as String, + badActivity: PracticeActivityModel.fromJson( + json['bad_activity'] as Map, + ), + ); + } + + Map toJson() { + return { + 'feedback_text': feedbackText, + 'bad_activity': badActivity.toJson(), + }; + } +} + class MessageActivityRequest { final String userL1; final String userL2; final String messageText; + final ActivityQualityFeedback? activityQualityFeedback; + /// tokens with their associated constructs and xp final List tokensWithXP; @@ -151,6 +181,7 @@ class MessageActivityRequest { required this.tokensWithXP, required this.messageId, required this.existingActivities, + required this.activityQualityFeedback, }); factory MessageActivityRequest.fromJson(Map json) { @@ -167,6 +198,11 @@ class MessageActivityRequest { (e) => ExistingActivityMetaData.fromJson(e as Map), ) .toList(), + activityQualityFeedback: json['activity_quality_feedback'] != null + ? ActivityQualityFeedback.fromJson( + json['activity_quality_feedback'] as Map, + ) + : null, ); } @@ -178,6 +214,7 @@ class MessageActivityRequest { 'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), 'message_id': messageId, 'existing_activities': existingActivities.map((e) => e.toJson()).toList(), + 'activity_quality_feedback': activityQualityFeedback?.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 4e93cb279..7c02a7aae 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -270,7 +270,8 @@ class PracticeActivityModel { factory PracticeActivityModel.fromJson(Map json) { return PracticeActivityModel( - tgtConstructs: (json['tgt_constructs'] as List) + tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs']) + as List) .map((e) => ConstructIdentifier.fromJson(e as Map)) .toList(), langCode: json['lang_code'] as String, @@ -278,7 +279,9 @@ class PracticeActivityModel { activityType: json['activity_type'] == "multipleChoice" ? ActivityTypeEnum.multipleChoice : ActivityTypeEnum.values.firstWhere( - (e) => e.string == json['activity_type'], + (e) => + e.string == json['activity_type'] as String || + e.string.split('.').last == json['activity_type'] as String, ), multipleChoice: json['multiple_choice'] != null ? MultipleChoice.fromJson( @@ -301,12 +304,13 @@ class PracticeActivityModel { RelevantSpanDisplayDetails? get relevantSpanDisplayDetails => multipleChoice?.spanDisplayDetails; + Map toJson() { return { - 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), + 'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(), 'lang_code': langCode, 'msg_id': msgId, - 'activity_type': activityType.toString().split('.').last, + 'activity_type': activityType.string, 'multiple_choice': multipleChoice?.toJson(), 'listening': listening?.toJson(), 'speaking': speaking?.toJson(), diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 9a8fa0f34..02b637ee1 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -76,6 +76,7 @@ class MessageAudioCardState extends State { @override Widget build(BuildContext context) { return Container( + padding: const EdgeInsets.all(8), child: _isLoading ? const ToolbarContentLoadingIndicator() : localAudioEvent != null || audioFile != null diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index d3db75e6e..34ea42ffc 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -57,6 +57,8 @@ class MessageOverlayController extends State int activitiesLeftToComplete = neededActivities; + PangeaMessageEvent get pangeaMessageEvent => widget._pangeaMessageEvent; + @override void initState() { super.initState(); @@ -301,13 +303,8 @@ class MessageOverlayController extends State ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ - Padding( - padding: EdgeInsets.only( - left: widget._pangeaMessageEvent.ownMessage - ? 0 - : Avatar.defaultSize + 16, - right: widget._pangeaMessageEvent.ownMessage ? 8 : 0, - ), + MessagePadding( + pangeaMessageEvent: pangeaMessageEvent, child: MessageToolbar( pangeaMessageEvent: widget._pangeaMessageEvent, overLayController: this, @@ -330,6 +327,13 @@ class MessageOverlayController extends State nextEvent: widget._nextEvent, previousEvent: widget._prevEvent, ), + // TODO for @ggurdin - move reactions and toolbar here + // MessageReactions(widget._event, widget.chatController.timeline!), + // const SizedBox(height: 6), + // MessagePadding( + // pangeaMessageEvent: pangeaMessageEvent, + // child: ToolbarButtons(overlayController: this, width: 250), + // ), ], ), ), @@ -396,3 +400,25 @@ class MessageOverlayController extends State ); } } + +class MessagePadding extends StatelessWidget { + const MessagePadding({ + super.key, + required this.child, + required this.pangeaMessageEvent, + }); + + final Widget child; + final PangeaMessageEvent pangeaMessageEvent; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: pangeaMessageEvent.ownMessage ? 0 : Avatar.defaultSize + 16, + right: pangeaMessageEvent.ownMessage ? 8 : 0, + ), + child: child, + ); + } +} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 23db604fd..f204aec47 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -1,16 +1,14 @@ 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'; 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_toolbar_buttons.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'; @@ -18,7 +16,6 @@ import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_ca import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; class MessageToolbar extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; @@ -35,33 +32,9 @@ class MessageToolbar extends StatefulWidget { } class MessageToolbarState extends State { - bool updatingMode = false; - @override void initState() { super.initState(); - - // 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); - // // } - // // }); - - // // just set mode based on messageSelectionOverlay mode which is now handling the state - // updateMode(widget.overLayController.toolbarMode); - // }); } Widget get toolbarContent { @@ -141,208 +114,50 @@ class MessageToolbarState extends State { @override Widget build(BuildContext context) { - debugPrint("building toolbar"); return Material( key: MatrixState.pAnyState .layerLinkAndKey('${widget.pangeaMessageEvent.eventId}-toolbar') .key, type: MaterialType.transparency, - child: Container( - constraints: const BoxConstraints( - maxHeight: AppConfig.toolbarMaxHeight, - maxWidth: 275, - minWidth: 275, - ), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).cardColor, - border: Border.all( - width: 2, - color: Theme.of(context).colorScheme.primary, - ), - borderRadius: const BorderRadius.all( - Radius.circular(25), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: SingleChildScrollView( - child: AnimatedSize( - duration: FluffyThemes.animationDuration, - child: toolbarContent, - ), + child: Column( + children: [ + Container( + constraints: const BoxConstraints( + maxHeight: AppConfig.toolbarMaxHeight, + maxWidth: 350, + minWidth: 350, + ), + padding: const EdgeInsets.all(0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + border: Border.all( + width: 2, + color: Theme.of(context).colorScheme.primary.withOpacity(0.5), + ), + borderRadius: const BorderRadius.all( + Radius.circular(AppConfig.borderRadius), ), ), - ToolbarButtons(messageToolbarController: this, width: 250), - ], - ), - ), - ); - } -} - -class ToolbarSelectionArea extends StatelessWidget { - final ChatController controller; - final PangeaMessageEvent? pangeaMessageEvent; - final bool isOverlay; - final Widget child; - final Event? nextEvent; - final Event? prevEvent; - - const ToolbarSelectionArea({ - required this.controller, - this.pangeaMessageEvent, - this.isOverlay = false, - required this.child, - this.nextEvent, - this.prevEvent, - super.key, - }); - - @override - Widget build(BuildContext context) { - return 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, - ); - } -} - -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; - - MessageOverlayController get overlayController => - widget.messageToolbarController.widget.overLayController; - - // @ggurdin - maybe this can be stateless now? - @override - void initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - final double barWidth = widget.width - iconWidth; - - if (widget - .messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) { - return const SizedBox(); - } - - 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: overlayController.isPracticeComplete - ? barWidth - : min( - barWidth, - (barWidth / 3) * - 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, - widget.messageToolbarController.widget - .overLayController.isPracticeComplete, - ), - ), - ), - onPressed: mode.isUnlocked( - index, - pangeaMessageEvent.numberOfActivitiesCompleted, - overlayController.isPracticeComplete, - ) - ? () => widget - .messageToolbarController.widget.overLayController - .updateToolbarMode(mode) - : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: SingleChildScrollView( + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: toolbarContent, ), ), - ) - .toList(), + ), + ], + ), ), + const SizedBox(height: 6), + ToolbarButtons( + overlayController: widget.overLayController, + width: 250, + ), + const SizedBox(height: 6), ], ), ); diff --git a/lib/pangea/widgets/chat/message_toolbar_buttons.dart b/lib/pangea/widgets/chat/message_toolbar_buttons.dart new file mode 100644 index 000000000..564538a75 --- /dev/null +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -0,0 +1,122 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:flutter/material.dart'; + +class ToolbarButtons extends StatefulWidget { + final MessageOverlayController overlayController; + final double width; + + const ToolbarButtons({ + required this.overlayController, + required this.width, + super.key, + }); + + @override + ToolbarButtonsState createState() => ToolbarButtonsState(); +} + +class ToolbarButtonsState extends State { + PangeaMessageEvent get pangeaMessageEvent => + widget.overlayController.pangeaMessageEvent; + + List get modes => MessageMode.values + .where((mode) => mode.isValidMode(pangeaMessageEvent.event)) + .toList(); + + static const double iconWidth = 36.0; + + MessageOverlayController get overlayController => widget.overlayController; + + // @ggurdin - maybe this can be stateless now? + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + final double barWidth = widget.width - iconWidth; + + if (widget.overlayController.pangeaMessageEvent.isAudioMessage) { + return const SizedBox(); + } + + 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: overlayController.isPracticeComplete + ? barWidth + : min( + barWidth, + (barWidth / 3) * + 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.overlayController.toolbarMode + ? Colors.white + : null, + isSelected: mode == widget.overlayController.toolbarMode, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + mode.iconButtonColor( + context, + index, + widget.overlayController.toolbarMode, + pangeaMessageEvent.numberOfActivitiesCompleted, + widget.overlayController.isPracticeComplete, + ), + ), + ), + onPressed: mode.isUnlocked( + index, + pangeaMessageEvent.numberOfActivitiesCompleted, + overlayController.isPracticeComplete, + ) + ? () => + widget.overlayController.updateToolbarMode(mode) + : null, + ), + ), + ) + .toList(), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/message_toolbar_selection_area.dart b/lib/pangea/widgets/chat/message_toolbar_selection_area.dart new file mode 100644 index 000000000..6ddc1026b --- /dev/null +++ b/lib/pangea/widgets/chat/message_toolbar_selection_area.dart @@ -0,0 +1,48 @@ +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class ToolbarSelectionArea extends StatelessWidget { + final ChatController controller; + final PangeaMessageEvent? pangeaMessageEvent; + final bool isOverlay; + final Widget child; + final Event? nextEvent; + final Event? prevEvent; + + const ToolbarSelectionArea({ + required this.controller, + this.pangeaMessageEvent, + this.isOverlay = false, + required this.child, + this.nextEvent, + this.prevEvent, + super.key, + }); + + @override + Widget build(BuildContext context) { + return 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, + ); + } +} diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index 891a5493f..081163558 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -151,6 +151,7 @@ class MessageTranslationCardState extends State { } return Container( + padding: const EdgeInsets.all(8), child: _fetchingTranslation ? const ToolbarContentLoadingIndicator() : Column( @@ -170,6 +171,7 @@ class MessageTranslationCardState extends State { body: InlineInstructions.l1Translation.body(context), onClose: closeHint, ), + // if (widget.selection != null) ], ), ); diff --git a/lib/pangea/widgets/igc/pangea_rich_text.dart b/lib/pangea/widgets/igc/pangea_rich_text.dart index e0b0e95ac..9da9b45b6 100644 --- a/lib/pangea/widgets/igc/pangea_rich_text.dart +++ b/lib/pangea/widgets/igc/pangea_rich_text.dart @@ -8,7 +8,7 @@ import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index aeb41d08b..54ff49544 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -174,56 +174,59 @@ class WordDataCardView extends StatelessWidget { final ScrollController scrollController = ScrollController(); - return Scrollbar( - thumbVisibility: true, - controller: scrollController, - child: SingleChildScrollView( + return Container( + padding: const EdgeInsets.all(8), + child: Scrollbar( + thumbVisibility: true, controller: scrollController, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (controller.widget.choiceFeedback != null) - Text( - controller.widget.choiceFeedback!, - style: BotStyle.text(context), - ), - const SizedBox(height: 5.0), - if (controller.wordData != null && - controller.wordNetError == null && - controller.activeL1 != null && - controller.activeL2 != null) - WordNetInfo( - wordData: controller.wordData!, - activeL1: controller.activeL1!, - activeL2: controller.activeL2!, - ), - if (controller.isLoadingWordNet) const PCircular(), - const SizedBox(height: 5.0), - // if (controller.widget.hasInfo && - // !controller.isLoadingContextualDefinition && - // controller.contextualDefinitionRes == null) - // Material( - // type: MaterialType.transparency, - // child: ListTile( - // leading: const BotFace( - // width: 40, expression: BotExpression.surprised), - // title: Text(L10n.of(context)!.askPangeaBot), - // onTap: controller.handleGetDefinitionButtonPress, - // ), - // ), - if (controller.isLoadingContextualDefinition) const PCircular(), - if (controller.contextualDefinitionRes != null) - Text( - controller.contextualDefinitionRes!.text, - style: BotStyle.text(context), - ), - if (controller.definitionError != null) - Text( - L10n.of(context)!.sorryNoResults, - style: BotStyle.text(context), - ), - ], + child: SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (controller.widget.choiceFeedback != null) + Text( + controller.widget.choiceFeedback!, + style: BotStyle.text(context), + ), + const SizedBox(height: 5.0), + if (controller.wordData != null && + controller.wordNetError == null && + controller.activeL1 != null && + controller.activeL2 != null) + WordNetInfo( + wordData: controller.wordData!, + activeL1: controller.activeL1!, + activeL2: controller.activeL2!, + ), + if (controller.isLoadingWordNet) const PCircular(), + const SizedBox(height: 5.0), + // if (controller.widget.hasInfo && + // !controller.isLoadingContextualDefinition && + // controller.contextualDefinitionRes == null) + // Material( + // type: MaterialType.transparency, + // child: ListTile( + // leading: const BotFace( + // width: 40, expression: BotExpression.surprised), + // title: Text(L10n.of(context)!.askPangeaBot), + // onTap: controller.handleGetDefinitionButtonPress, + // ), + // ), + if (controller.isLoadingContextualDefinition) const PCircular(), + if (controller.contextualDefinitionRes != null) + Text( + controller.contextualDefinitionRes!.text, + style: BotStyle.text(context), + ), + if (controller.definitionError != null) + Text( + L10n.of(context)!.sorryNoResults, + style: BotStyle.text(context), + ), + ], + ), ), ), ); @@ -405,11 +408,17 @@ class SelectToDefine extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Text( - L10n.of(context)!.selectToDefine, - style: BotStyle.text(context), + return Center( + child: Container( + height: 80, + width: 200, + padding: const EdgeInsets.all(8), + child: Center( + child: Text( + L10n.of(context)!.selectToDefine, + style: BotStyle.text(context), + ), + ), ), ); } diff --git a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart index cdcacea35..46df44ce5 100644 --- a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart +++ b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart @@ -81,6 +81,7 @@ class GamifiedTextWidget extends StatelessWidget { constraints: const BoxConstraints( minHeight: 80, ), + padding: const EdgeInsets.all(8), child: Text( userMessage, style: const TextStyle( diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 0e8d4051a..067288157 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -6,7 +6,6 @@ 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/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; @@ -16,6 +15,7 @@ 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/pangea/widgets/practice_activity/no_more_practice_card.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -44,6 +44,8 @@ class MessagePracticeActivityCardState extends State { PracticeActivityRecordModel? currentCompletionRecord; bool fetchingActivity = false; + // tracks the target tokens for the current message + // in a separate controller to manage the state TargetTokensController targetTokensController = TargetTokensController(); List get practiceActivities => @@ -108,7 +110,9 @@ class MessagePracticeActivityCardState extends State { return incompleteActivities.firstOrNull; } - Future _fetchNewActivity() async { + Future _fetchNewActivity([ + ActivityQualityFeedback? activityFeedback, + ]) async { try { debugPrint('Fetching new activity'); @@ -143,6 +147,7 @@ class MessagePracticeActivityCardState extends State { existingActivities: practiceActivities .map((activity) => activity.activityRequestMetaData) .toList(), + activityQualityFeedback: activityFeedback, ), widget.pangeaMessageEvent, ); @@ -192,62 +197,138 @@ class MessagePracticeActivityCardState extends State { /// Fetches a new activity if there are any left to complete. /// Exits the practice flow if there are no more activities. void onActivityFinish() async { - // try { - if (currentCompletionRecord == null || currentActivity == null) { + try { + if (currentCompletionRecord == null || currentActivity == null) { + debugger(when: kDebugMode); + return; + } + + // update the target tokens with the new construct uses + // NOTE - multiple choice activity is handling adding these to analytics + await targetTokensController.updateTokensWithConstructs( + currentCompletionRecord!.usesForAllResponses( + currentActivity!.practiceActivity, + metadata, + ), + context, + widget.pangeaMessageEvent, + ); + + // save the record without awaiting to avoid blocking the UI + // send a copy of the activity record to make sure its not overwritten by + // the new activity + MatrixState.pangeaController.activityRecordController + .send(currentCompletionRecord!, currentActivity!) + .catchError( + (e, s) => ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to save record', + data: { + 'record': currentCompletionRecord?.toJson(), + 'activity': currentActivity?.practiceActivity.toJson(), + }, + ), + ); + + widget.overlayController.onActivityFinish(); + + final Iterable result = await Future.wait([ + _savorTheJoy(), + _fetchNewActivity(), + ]); + + _setPracticeActivity(result.last as PracticeActivityEvent?); + } catch (e, s) { + _setPracticeActivity(null); + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to get new activity', + data: { + 'activity': currentActivity, + 'record': currentCompletionRecord, + }, + ); + } + } + + void onFlagClick(BuildContext context) { + final TextEditingController feedbackController = TextEditingController(); + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(L10n.of(context)!.reportContentIssueTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(L10n.of(context)!.reportContentIssueDescription), + const SizedBox(height: 10), + TextField( + controller: feedbackController, + decoration: InputDecoration( + labelText: L10n.of(context)!.feedback, + border: const OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close the dialog + }, + child: Text(L10n.of(context)!.cancel), + ), + ElevatedButton( + onPressed: () { + // Call the additional callback function + submitFeedback(feedbackController.text); + Navigator.of(context).pop(); // Close the dialog + }, + child: Text(L10n.of(context)!.submit), + ), + ], + ); + }, + ); + } + + /// clear the current activity, record, and selection + /// fetch a new activity, including the offending activity in the request + void submitFeedback(String feedback) { + if (currentActivity == null) { debugger(when: kDebugMode); return; } - // update the target tokens with the new construct uses - // NOTE - multiple choice activity is handling adding these to analytics - await targetTokensController.updateTokensWithConstructs( - currentCompletionRecord!.usesForAllResponses( - currentActivity!.practiceActivity, - metadata, + _fetchNewActivity( + ActivityQualityFeedback( + feedbackText: feedback, + badActivity: currentActivity!.practiceActivity, ), - context, - widget.pangeaMessageEvent, - ); + ).then((activity) { + _setPracticeActivity(activity); + }).catchError((onError) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: onError, + m: 'Failed to get new activity', + data: { + 'activity': currentActivity, + 'record': currentCompletionRecord, + }, + ); + widget.overlayController.exitPracticeFlow(); + }); - // save the record without awaiting to avoid blocking the UI - // send a copy of the activity record to make sure its not overwritten by - // the new activity - MatrixState.pangeaController.activityRecordController - .send(currentCompletionRecord!, currentActivity!) - .catchError( - (e, s) => ErrorHandler.logError( - e: e, - s: s, - m: 'Failed to save record', - data: { - 'record': currentCompletionRecord?.toJson(), - 'activity': currentActivity?.practiceActivity.toJson(), - }, - ), - ); - - widget.overlayController.onActivityFinish(); - - final Iterable result = await Future.wait([ - _savorTheJoy(), - _fetchNewActivity(), - ]); - - _setPracticeActivity(result.last as PracticeActivityEvent?); - - // } catch (e, s) { - // debugger(when: kDebugMode); - // ErrorHandler.logError( - // e: e, - // s: s, - // m: 'Failed to get new activity', - // data: { - // 'activity': currentActivity, - // 'record': currentCompletionRecord, - // }, - // ); - // widget.overlayController.exitPracticeFlow(); - // } + // clear the current activity and record + currentActivity = null; + currentCompletionRecord = null; } RepresentationEvent? get representation => @@ -300,120 +381,52 @@ class MessagePracticeActivityCardState extends State { return GamifiedTextWidget(userMessage: userMessage!); } - return Stack( - alignment: Alignment.center, - children: [ - // Main content - const Positioned( - 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 + return Container( + constraints: const BoxConstraints( + maxWidth: 350, + minWidth: 350, + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Main content + const Positioned( + child: PointsGainedAnimation(), ), - // Circular progress indicator in the center - const Center( - child: CircularProgressIndicator(), + Container( + padding: const EdgeInsets.all(8), + child: activityWidget, + ), + // 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(), + ), + ], + // Flag button in the top right corner + Positioned( + top: 0, + right: 0, + child: Opacity( + opacity: 0.8, // Slight opacity + child: Tooltip( + message: L10n.of(context)!.reportContentIssueTitle, + child: IconButton( + icon: const Icon(Icons.flag), + iconSize: 16, + onPressed: () => + currentActivity == null ? null : onFlagClick(context), + ), + ), + ), ), ], - ], + ), ); } } - -/// Seperated out the target tokens from the practice activity card -/// in order to control the state of the target tokens -class TargetTokensController { - List? _targetTokens; - - TargetTokensController(); - - /// From the tokens in the message, do a preliminary filtering of which to target - /// Then get the construct uses for those tokens - Future> targetTokens( - BuildContext context, - PangeaMessageEvent pangeaMessageEvent, - ) async { - if (_targetTokens != null) { - return _targetTokens!; - } - - _targetTokens = await _initialize(context, pangeaMessageEvent); - - await updateTokensWithConstructs( - MatrixState.pangeaController.analytics.analyticsStream.value ?? [], - context, - pangeaMessageEvent, - ); - - return _targetTokens!; - } - - Future> _initialize( - BuildContext context, - PangeaMessageEvent pangeaMessageEvent, - ) async { - if (!context.mounted) { - ErrorHandler.logError( - m: 'getTargetTokens called when not mounted', - s: StackTrace.current, - ); - return _targetTokens = []; - } - - final tokens = await pangeaMessageEvent - .representationByLanguage(pangeaMessageEvent.messageDisplayLangCode) - ?.tokensGlobal(context); - - if (tokens == null || tokens.isEmpty) { - debugger(when: kDebugMode); - return _targetTokens = []; - } - - _targetTokens = []; - 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; - } - - _targetTokens!.add(tokens[i].emptyTokenWithXP); - } - - return _targetTokens!; - } - - Future updateTokensWithConstructs( - List constructUses, - context, - pangeaMessageEvent, - ) async { - final ConstructListModel constructList = ConstructListModel( - uses: constructUses, - type: null, - ); - - _targetTokens ??= await _initialize(context, pangeaMessageEvent); - - for (final token in _targetTokens!) { - for (final construct in token.constructs) { - final constructUseModel = constructList.getConstructUses( - construct.id.lemma, - construct.id.type, - ); - if (constructUseModel != null) { - construct.xp += constructUseModel.points; - construct.lastUsed = constructUseModel.lastUsed; - } - } - } - } -} diff --git a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart new file mode 100644 index 000000000..f22e097e4 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart @@ -0,0 +1,99 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Seperated out the target tokens from the practice activity card +/// in order to control the state of the target tokens +class TargetTokensController { + List? _targetTokens; + + TargetTokensController(); + + /// From the tokens in the message, do a preliminary filtering of which to target + /// Then get the construct uses for those tokens + Future> targetTokens( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (_targetTokens != null) { + return _targetTokens!; + } + + _targetTokens = await _initialize(context, pangeaMessageEvent); + + await updateTokensWithConstructs( + MatrixState.pangeaController.analytics.analyticsStream.value ?? [], + context, + pangeaMessageEvent, + ); + + return _targetTokens!; + } + + Future> _initialize( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (!context.mounted) { + ErrorHandler.logError( + m: 'getTargetTokens called when not mounted', + s: StackTrace.current, + ); + return _targetTokens = []; + } + + final tokens = await pangeaMessageEvent + .representationByLanguage(pangeaMessageEvent.messageDisplayLangCode) + ?.tokensGlobal(context); + + if (tokens == null || tokens.isEmpty) { + debugger(when: kDebugMode); + return _targetTokens = []; + } + + _targetTokens = []; + 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; + } + + _targetTokens!.add(tokens[i].emptyTokenWithXP); + } + + return _targetTokens!; + } + + Future updateTokensWithConstructs( + List constructUses, + context, + pangeaMessageEvent, + ) async { + final ConstructListModel constructList = ConstructListModel( + uses: constructUses, + type: null, + ); + + _targetTokens ??= await _initialize(context, pangeaMessageEvent); + + for (final token in _targetTokens!) { + for (final construct in token.constructs) { + final constructUseModel = constructList.getConstructUses( + construct.id.lemma, + construct.id.type, + ); + if (constructUseModel != null) { + construct.xp += constructUseModel.points; + construct.lastUsed = constructUseModel.lastUsed; + } + } + } + } +} diff --git a/pubspec.lock b/pubspec.lock index ed344d6a6..bb56964d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1305,18 +1305,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -1417,10 +1417,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" material_symbols_icons: dependency: "direct main" description: @@ -1442,10 +1442,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" mgrs_dart: dependency: transitive description: @@ -1682,10 +1682,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" platform_detect: dependency: transitive description: @@ -2303,26 +2303,26 @@ packages: dependency: transitive description: name: test - sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" url: "https://pub.dev" source: hosted - version: "1.25.2" + version: "1.25.7" test_api: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" test_core: dependency: transitive description: name: test_core - sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.4" timezone: dependency: transitive description: @@ -2615,10 +2615,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: From b7ab6038ac84d3d7ae7c62078bc96ed684d44890 Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:52:56 -0400 Subject: [PATCH 15/25] further toolbar tweaks * remove print statement * ending animation, savoring joy, properly adding xp in session * forgot to switch env again... * increment version number * about to move toolbar buttons up to level of overlay controller * added ability to give feedback and get new activity * more practice tweaks and instructions too * incrementing pubspec version --- assets/l10n/intl_en.arb | 13 ++- assets/l10n/intl_es.arb | 6 - lib/pages/chat/chat.dart | 1 + .../controllers/choreographer.dart | 9 +- .../controllers/it_controller.dart | 15 +-- lib/pangea/choreographer/widgets/it_bar.dart | 15 +-- lib/pangea/enum/instructions_enum.dart | 89 ++++++++++----- lib/pangea/models/igc_text_data_model.dart | 17 ++- lib/pangea/models/pangea_token_model.dart | 11 +- lib/pangea/models/user_model.dart | 47 ++++---- lib/pangea/utils/inline_tooltip.dart | 81 ++++++++------ lib/pangea/utils/instructions.dart | 105 ++++++------------ lib/pangea/utils/match_copy.dart | 2 +- .../widgets/chat/message_audio_card.dart | 32 +++--- .../chat/message_selection_overlay.dart | 1 - .../chat/message_speech_to_text_card.dart | 29 +---- lib/pangea/widgets/chat/message_toolbar.dart | 5 +- .../chat/message_translation_card.dart | 37 ++---- .../chat/message_unsubscribed_card.dart | 84 +++++++------- .../widgets/chat/overlay_message_text.dart | 2 +- .../widgets/common/icon_number_widget.dart | 4 +- lib/pangea/widgets/igc/word_data_card.dart | 1 - .../practice_activity_card.dart | 3 +- pubspec.yaml | 2 +- 24 files changed, 292 insertions(+), 319 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index de1b2f8e7..e8e2aa585 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3149,7 +3149,7 @@ "generateVocabulary": "Generate vocabulary from title and description", "generatePrompts": "Generate prompts", "subscribe": "Subscribe", - "getAccess": "Unlock learning tools", + "getAccess": "Subscribe now!", "subscriptionDesc": "Messaging is free! Subscribe to unlock interactive translation, grammar checking and learning analytics.", "subscriptionManagement": "Subscription Management", "currentSubscription": "Current Subscription", @@ -3788,7 +3788,7 @@ } }, "freeTrialDesc": "New users recieve a one week free trial of Pangea Chat", - "activateTrial": "Activate Free Trial", + "activateTrial": "Free 7-Day Trial", "inNoSpaces": "You are not a member of any spaces", "successfullySubscribed": "You have successfully subscribed!", "clickToManageSubscription": "Click here to manage your subscription.", @@ -3968,11 +3968,11 @@ "seeOptions": "See options", "continuedWithoutSubscription": "Continue without subscribing", "trialPeriodExpired": "Your trial period has expired", - "selectToDefine": "Click a word to see its definition!", + "selectToDefine": "Click any word to see its definition!", "translations": "translations", "messageAudio": "message audio", "definitions": "definitions", - "subscribedToUnlockTools": "Subscribe to unlock language tools, including", + "subscribedToUnlockTools": "Subscribe to unlock interactive translation and grammar checking, audio playback, personalized practice activities, and learning analytics!", "more": "More", "translationTooltip": "Translate", "audioTooltip": "Play Audio", @@ -4162,7 +4162,7 @@ "placeholders": {} }, "changeAnalyticsView": "Change Analytics View", - "l1TranslationBody": "Oops! It looks like this message wasn't sent in your target language. Messages not sent in your target language will not be translated.", + "l1TranslationBody": "Messages in your base language will not be translated.", "continueText": "Continue", "deleteSubscriptionWarningTitle": "You have an active subscription", "deleteSubscriptionWarningBody": "Deleting your account will not automatically cancel your subscription.", @@ -4232,5 +4232,6 @@ "chatName": "Chat name", "reportContentIssueTitle": "Report content issue", "feedback": "Your feedback (optional)", - "reportContentIssueDescription": "Sorry! AI can make personalized experiences but also may have issues. Please provide any feedback you have and we'll generate a new activity." + "reportContentIssueDescription": "Sorry! AI can make personalized experiences but also may have issues. Please provide any feedback you have and we'll generate a new activity.", + "clickTheWordAgainToDeselect": "Click the selected word to deselect it." } \ No newline at end of file diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index 632ff11ee..2d374f381 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4329,7 +4329,6 @@ "pleaseTryAgainLaterOrChooseDifferentServer": "", "@pleaseTryAgainLaterOrChooseDifferentServer": {}, "createGroup": "", - "@createGroup": {}, "@noBackupWarning": {}, "kickUserDescription": "", "@kickUserDescription": {}, @@ -4490,7 +4489,6 @@ "translations": "traducciónes", "messageAudio": "mensaje de audio", "definitions": "definiciones", - "subscribedToUnlockTools": "Suscríbase para desbloquear herramientas lingüísticas, como", "clickMessageTitle": "¿Necesitas ayuda?", "clickMessageBody": "¡Lame un mensaje para obtener ayuda con el idioma! Haz clic y mantén presionado para reaccionar 😀", "more": "Más", @@ -4686,7 +4684,6 @@ "fetchingVersion": "Obteniendo versión...", "versionFetchError": "Error al obtener la versión", "connectedToStaging": "Conectado al entorno de pruebas", - "connectedToStaging": "Conectado al entorno de pruebas", "versionText": "Versión: {version}+{buildNumber}", "@versionText": { "description": "Texto que muestra la versión y el número de compilación de la aplicación.", @@ -4710,8 +4707,6 @@ }, "emojis": "Emojis", "@emojis": {}, - "createGroup": "Crear grupo", - "@createGroup": {}, "hydrateTorLong": "¿Exportó su sesión la última vez que estuvo en TOR? Impórtela rápidamente y continúe chateando.", "@hydrateTorLong": {}, "custom": "Personalizado", @@ -4737,6 +4732,5 @@ } }, "commandHint_googly": "Enviar unos ojos saltones", - "@commandHint_googly": {}, "reportContentIssue": "Problema de contenido" } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 47998523c..2dd0ae476 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -552,6 +552,7 @@ class ChatController extends State //#Pangea choreographer.stateListener.close(); choreographer.dispose(); + MatrixState.pAnyState.closeOverlay(); //Pangea# super.dispose(); } diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 389c5fad5..9053d3353 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -109,9 +109,14 @@ class Choreographer { // 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); + if (kDebugMode) { + PangeaToken.reconstructText( + igc.igcTextData!.tokens, + debugWalkThrough: true, + ); + } ErrorHandler.logError( - m: "reconstructed text does not match current text", + m: "reconstructed text not working", s: StackTrace.current, data: { "igcTextData": igc.igcTextData?.toJson(), diff --git a/lib/pangea/choreographer/controllers/it_controller.dart b/lib/pangea/choreographer/controllers/it_controller.dart index c3ce49575..636415b8e 100644 --- a/lib/pangea/choreographer/controllers/it_controller.dart +++ b/lib/pangea/choreographer/controllers/it_controller.dart @@ -56,23 +56,10 @@ class ITController { choreographer.setState(); } - bool _closingHint = false; - Duration get animationSpeed => (_closingHint || !_willOpen) + Duration get animationSpeed => (!_willOpen) ? const Duration(milliseconds: 500) : const Duration(milliseconds: 2000); - void closeHint() { - _closingHint = true; - final String hintKey = InlineInstructions.translationChoices.toString(); - final instructionsController = choreographer.pangeaController.instructions; - instructionsController.turnOffInstruction(hintKey); - instructionsController.updateEnableInstructions(hintKey, true); - choreographer.setState(); - Future.delayed(const Duration(milliseconds: 500), () { - _closingHint = false; - }); - } - Future initializeIT(ITStartData itStartData) async { _willOpen = true; Future.delayed(const Duration(microseconds: 100), () { diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 768783dd0..2fc20531f 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -49,12 +49,6 @@ class ITBarState extends State { super.dispose(); } - bool get instructionsTurnedOff => - widget.choreographer.pangeaController.instructions - .wereInstructionsTurnedOff( - InlineInstructions.translationChoices.toString(), - ); - @override Widget build(BuildContext context) { return AnimatedSize( @@ -120,11 +114,12 @@ class ITBarState extends State { // const SizedBox(height: 40.0), OriginalText(controller: itController), const SizedBox(height: 7.0), - if (!instructionsTurnedOff) + if (!InstructionsEnum.translationChoices + .toggledOff(context)) InlineTooltip( - body: InlineInstructions.translationChoices - .body(context), - onClose: itController.closeHint, + instructionsEnum: + InstructionsEnum.translationChoices, + onClose: () => setState(() {}), ), IntrinsicHeight( child: Container( diff --git a/lib/pangea/enum/instructions_enum.dart b/lib/pangea/enum/instructions_enum.dart index 48544925e..e1a403526 100644 --- a/lib/pangea/enum/instructions_enum.dart +++ b/lib/pangea/enum/instructions_enum.dart @@ -1,5 +1,9 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/utils/platform_infos.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'; @@ -8,10 +12,22 @@ enum InstructionsEnum { clickMessage, blurMeansTranslate, tooltipInstructions, + speechToText, + l1Translation, + translationChoices, + clickAgainToDeselect, } extension InstructionsEnumExtension on InstructionsEnum { String title(BuildContext context) { + if (!context.mounted) { + ErrorHandler.logError( + e: Exception("Context not mounted"), + m: 'InstructionsEnumExtension.title for $this', + ); + debugger(when: kDebugMode); + return ''; + } switch (this) { case InstructionsEnum.itInstructions: return L10n.of(context)!.itInstructionsTitle; @@ -21,10 +37,31 @@ extension InstructionsEnumExtension on InstructionsEnum { return L10n.of(context)!.blurMeansTranslateTitle; case InstructionsEnum.tooltipInstructions: return L10n.of(context)!.tooltipInstructionsTitle; + case InstructionsEnum.clickAgainToDeselect: + case InstructionsEnum.speechToText: + case InstructionsEnum.l1Translation: + case InstructionsEnum.translationChoices: + ErrorHandler.logError( + e: Exception("No title for this instruction"), + m: 'InstructionsEnumExtension.title', + data: { + 'this': this, + }, + ); + debugger(when: kDebugMode); + return ""; } } String body(BuildContext context) { + if (!context.mounted) { + ErrorHandler.logError( + e: Exception("Context not mounted"), + m: 'InstructionsEnumExtension.body for $this', + ); + debugger(when: kDebugMode); + return ""; + } switch (this) { case InstructionsEnum.itInstructions: return L10n.of(context)!.itInstructionsBody; @@ -32,6 +69,14 @@ extension InstructionsEnumExtension on InstructionsEnum { return L10n.of(context)!.clickMessageBody; case InstructionsEnum.blurMeansTranslate: return L10n.of(context)!.blurMeansTranslateBody; + case InstructionsEnum.speechToText: + return L10n.of(context)!.speechToTextBody; + case InstructionsEnum.l1Translation: + return L10n.of(context)!.l1TranslationBody; + case InstructionsEnum.translationChoices: + return L10n.of(context)!.translationChoicesBody; + case InstructionsEnum.clickAgainToDeselect: + return L10n.of(context)!.clickTheWordAgainToDeselect; case InstructionsEnum.tooltipInstructions: return PlatformInfos.isMobile ? L10n.of(context)!.tooltipInstructionsMobileBody @@ -39,7 +84,15 @@ extension InstructionsEnumExtension on InstructionsEnum { } } - bool get toggledOff { + bool toggledOff(BuildContext context) { + if (!context.mounted) { + ErrorHandler.logError( + e: Exception("Context not mounted"), + m: 'InstructionsEnumExtension.toggledOff for $this', + ); + debugger(when: kDebugMode); + return false; + } final instructionSettings = MatrixState.pangeaController.userController.profile.instructionSettings; switch (this) { @@ -51,38 +104,14 @@ extension InstructionsEnumExtension on InstructionsEnum { return instructionSettings.showedBlurMeansTranslate; case InstructionsEnum.tooltipInstructions: return instructionSettings.showedTooltipInstructions; - } - } -} - -enum InlineInstructions { - speechToText, - l1Translation, - translationChoices, -} - -extension InlineInstructionsExtension on InlineInstructions { - String body(BuildContext context) { - switch (this) { - case InlineInstructions.speechToText: - return L10n.of(context)!.speechToTextBody; - case InlineInstructions.l1Translation: - return L10n.of(context)!.l1TranslationBody; - case InlineInstructions.translationChoices: - return L10n.of(context)!.translationChoicesBody; - } - } - - bool get toggledOff { - final instructionSettings = - MatrixState.pangeaController.userController.profile.instructionSettings; - switch (this) { - case InlineInstructions.speechToText: + case InstructionsEnum.speechToText: return instructionSettings.showedSpeechToTextTooltip; - case InlineInstructions.l1Translation: + case InstructionsEnum.l1Translation: return instructionSettings.showedL1TranslationTooltip; - case InlineInstructions.translationChoices: + case InstructionsEnum.translationChoices: return instructionSettings.showedTranslationChoicesTooltip; + case InstructionsEnum.clickAgainToDeselect: + return instructionSettings.showedClickAgainToDeselect; } } } diff --git a/lib/pangea/models/igc_text_data_model.dart b/lib/pangea/models/igc_text_data_model.dart index 180273e30..60497955b 100644 --- a/lib/pangea/models/igc_text_data_model.dart +++ b/lib/pangea/models/igc_text_data_model.dart @@ -117,6 +117,7 @@ class IGCTextData { ) async { //should be already added to choreoRecord //TODO - that should be done in the same function to avoid error potential + final PangeaMatch pangeaMatch = matches[matchIndex]; if (pangeaMatch.match.choices == null) { @@ -147,8 +148,8 @@ class IGCTextData { // 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; + tokens[i].text.offset += + replacement.value.length - pangeaMatch.match.length; } // clone the list for debugging purposes @@ -159,10 +160,16 @@ class IGCTextData { final String newFullText = PangeaToken.reconstructText(newTokens); - if (newFullText != originalInput) { - debugger(when: kDebugMode); + if (newFullText != originalInput && kDebugMode) { + PangeaToken.reconstructText(newTokens, debugWalkThrough: true); ErrorHandler.logError( - m: "reconstructed text does not match original input", + m: "reconstructed text not working", + s: StackTrace.current, + data: { + "originalInput": originalInput, + "newFullText": newFullText, + "match": pangeaMatch.match.toJson(), + }, ); } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index b8a73b65a..e6b577c20 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -28,11 +28,17 @@ class PangeaToken { required this.morph, }); + /// reconstructs the text from the tokens + /// [tokens] - the tokens to reconstruct + /// [debugWalkThrough] - if true, will start the debugger static String reconstructText( - List tokens, [ + List tokens, { + bool debugWalkThrough = false, int startTokenIndex = 0, int endTokenIndex = -1, - ]) { + }) { + debugger(when: kDebugMode && debugWalkThrough); + if (endTokenIndex == -1) { endTokenIndex = tokens.length; } @@ -55,7 +61,6 @@ class PangeaToken { (i > 0 ? (subset[i - 1].text.offset + subset[i - 1].text.length) : 0); if (whitespace < 0) { - debugger(when: kDebugMode); whitespace = 0; } reconstruction += ' ' * whitespace + subset[i].text.content; diff --git a/lib/pangea/models/user_model.dart b/lib/pangea/models/user_model.dart index 34518f645..7ef85fbd5 100644 --- a/lib/pangea/models/user_model.dart +++ b/lib/pangea/models/user_model.dart @@ -189,6 +189,7 @@ class UserInstructions { bool showedSpeechToTextTooltip; bool showedL1TranslationTooltip; bool showedTranslationChoicesTooltip; + bool showedClickAgainToDeselect; UserInstructions({ this.showedItInstructions = false, @@ -198,12 +199,12 @@ class UserInstructions { this.showedSpeechToTextTooltip = false, this.showedL1TranslationTooltip = false, this.showedTranslationChoicesTooltip = false, + this.showedClickAgainToDeselect = false, }); factory UserInstructions.fromJson(Map json) => UserInstructions( - showedItInstructions: - json[InstructionsEnum.itInstructions.toString()] ?? false, + showedItInstructions: json[InstructionsEnum.itInstructions.toString()], showedClickMessage: json[InstructionsEnum.clickMessage.toString()] ?? false, showedBlurMeansTranslate: @@ -211,11 +212,13 @@ class UserInstructions { showedTooltipInstructions: json[InstructionsEnum.tooltipInstructions.toString()] ?? false, showedL1TranslationTooltip: - json[InlineInstructions.l1Translation.toString()] ?? false, + json[InstructionsEnum.l1Translation.toString()] ?? false, showedTranslationChoicesTooltip: - json[InlineInstructions.translationChoices.toString()] ?? false, + json[InstructionsEnum.translationChoices.toString()] ?? false, showedSpeechToTextTooltip: - json[InlineInstructions.speechToText.toString()] ?? false, + json[InstructionsEnum.speechToText.toString()] ?? false, + showedClickAgainToDeselect: + json[InstructionsEnum.clickAgainToDeselect.toString()] ?? false, ); Map toJson() { @@ -226,12 +229,13 @@ class UserInstructions { showedBlurMeansTranslate; data[InstructionsEnum.tooltipInstructions.toString()] = showedTooltipInstructions; - data[InlineInstructions.l1Translation.toString()] = + data[InstructionsEnum.l1Translation.toString()] = showedL1TranslationTooltip; - data[InlineInstructions.translationChoices.toString()] = + data[InstructionsEnum.translationChoices.toString()] = showedTranslationChoicesTooltip; - data[InlineInstructions.speechToText.toString()] = - showedSpeechToTextTooltip; + data[InstructionsEnum.speechToText.toString()] = showedSpeechToTextTooltip; + data[InstructionsEnum.clickAgainToDeselect.toString()] = + showedClickAgainToDeselect; return data; } @@ -258,20 +262,25 @@ class UserInstructions { as bool?) ?? false, showedL1TranslationTooltip: - (accountData[InlineInstructions.l1Translation.toString()] - ?.content[InlineInstructions.l1Translation.toString()] + (accountData[InstructionsEnum.l1Translation.toString()] + ?.content[InstructionsEnum.l1Translation.toString()] as bool?) ?? false, - showedTranslationChoicesTooltip: (accountData[ - InlineInstructions.translationChoices.toString()] - ?.content[InlineInstructions.translationChoices.toString()] + showedTranslationChoicesTooltip: + (accountData[InstructionsEnum.translationChoices.toString()] + ?.content[InstructionsEnum.translationChoices.toString()] + as bool?) ?? + false, + showedSpeechToTextTooltip: + (accountData[InstructionsEnum.speechToText.toString()] + ?.content[InstructionsEnum.speechToText.toString()] + as bool?) ?? + false, + showedClickAgainToDeselect: (accountData[ + InstructionsEnum.clickAgainToDeselect.toString()] + ?.content[InstructionsEnum.clickAgainToDeselect.toString()] as bool?) ?? false, - showedSpeechToTextTooltip: - (accountData[InlineInstructions.speechToText.toString()] - ?.content[InlineInstructions.speechToText.toString()] - as bool?) ?? - false, ); } } diff --git a/lib/pangea/utils/inline_tooltip.dart b/lib/pangea/utils/inline_tooltip.dart index f82a11682..f0d95c6f7 100644 --- a/lib/pangea/utils/inline_tooltip.dart +++ b/lib/pangea/utils/inline_tooltip.dart @@ -1,60 +1,71 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/enum/instructions_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; class InlineTooltip extends StatelessWidget { - final String body; + final InstructionsEnum instructionsEnum; final VoidCallback onClose; const InlineTooltip({ super.key, - required this.body, + required this.instructionsEnum, required this.onClose, }); @override Widget build(BuildContext context) { - return Badge( - offset: const Offset(0, -7), - backgroundColor: Colors.transparent, - label: CircleAvatar( - radius: 10, - child: IconButton( - padding: EdgeInsets.zero, - icon: const Icon( - Icons.close_outlined, - size: 15, - ), - onPressed: onClose, - ), - ), + if (instructionsEnum.toggledOff(context)) { + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.all(8.0), child: DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), color: Theme.of(context).colorScheme.primary.withAlpha(20), ), child: Padding( - padding: const EdgeInsets.all(10), - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - children: [ - const WidgetSpan( - child: Icon( - Icons.lightbulb, - size: 16, - ), - ), - const WidgetSpan( - child: SizedBox(width: 5), - ), - TextSpan( - text: body, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Lightbulb icon on the left + Icon( + Icons.lightbulb, + size: 20, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 8), + // Text in the middle + Expanded( + child: Text( + instructionsEnum.body(context), style: TextStyle( color: Theme.of(context).colorScheme.onSurface, height: 1.5, ), + textAlign: TextAlign.left, ), - ], - ), + ), + // Close button on the right + IconButton( + constraints: const BoxConstraints(), + icon: Icon( + Icons.close_outlined, + size: 20, + color: Theme.of(context).colorScheme.onSurface, + ), + onPressed: () { + MatrixState.pangeaController.instructions.setToggledOff( + instructionsEnum, + true, + ); + onClose(); + }, + ), + ], ), ), ), diff --git a/lib/pangea/utils/instructions.dart b/lib/pangea/utils/instructions.dart index 78dab6f6c..ca76b0629 100644 --- a/lib/pangea/utils/instructions.dart +++ b/lib/pangea/utils/instructions.dart @@ -1,6 +1,4 @@ -import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/instructions_enum.dart'; -import 'package:fluffychat/pangea/utils/inline_tooltip.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -24,54 +22,40 @@ class InstructionsController { /// Instruction popup has already been shown this session final Map _instructionsShown = {}; - /// Returns true if the user requested this popup not be shown again - bool? toggledOff(String key) { - final bool? instruction = InstructionsEnum.values - .firstWhereOrNull((value) => value.toString() == key) - ?.toggledOff; - final bool? tooltip = InlineInstructions.values - .firstWhereOrNull((value) => value.toString() == key) - ?.toggledOff; - return instruction ?? tooltip; - } - InstructionsController(PangeaController pangeaController) { _pangeaController = pangeaController; } - /// Returns true if the instructions were closed - /// or turned off by the user via the toggle switch - bool wereInstructionsTurnedOff(String key) { - return toggledOff(key) ?? _instructionsClosed[key] ?? false; - } - - void turnOffInstruction(String key) => _instructionsClosed[key] = true; - - void updateEnableInstructions( - String key, + void setToggledOff( + InstructionsEnum key, bool value, ) { _pangeaController.userController.updateProfile((profile) { - if (key == InstructionsEnum.itInstructions.toString()) { - profile.instructionSettings.showedItInstructions = value; - } - if (key == InstructionsEnum.clickMessage.toString()) { - profile.instructionSettings.showedClickMessage = value; - } - if (key == InstructionsEnum.blurMeansTranslate.toString()) { - profile.instructionSettings.showedBlurMeansTranslate = value; - } - if (key == InstructionsEnum.tooltipInstructions.toString()) { - profile.instructionSettings.showedTooltipInstructions = value; - } - if (key == InlineInstructions.speechToText.toString()) { - profile.instructionSettings.showedSpeechToTextTooltip = value; - } - if (key == InlineInstructions.l1Translation.toString()) { - profile.instructionSettings.showedL1TranslationTooltip = value; - } - if (key == InlineInstructions.translationChoices.toString()) { - profile.instructionSettings.showedTranslationChoicesTooltip = value; + switch (key) { + case InstructionsEnum.speechToText: + profile.instructionSettings.showedSpeechToTextTooltip = value; + break; + case InstructionsEnum.l1Translation: + profile.instructionSettings.showedL1TranslationTooltip = value; + break; + case InstructionsEnum.translationChoices: + profile.instructionSettings.showedTranslationChoicesTooltip = value; + break; + case InstructionsEnum.tooltipInstructions: + profile.instructionSettings.showedTooltipInstructions = value; + break; + case InstructionsEnum.itInstructions: + profile.instructionSettings.showedItInstructions = value; + break; + case InstructionsEnum.clickMessage: + profile.instructionSettings.showedClickMessage = value; + break; + case InstructionsEnum.blurMeansTranslate: + profile.instructionSettings.showedBlurMeansTranslate = value; + break; + case InstructionsEnum.clickAgainToDeselect: + profile.instructionSettings.showedClickAgainToDeselect = value; + break; } return profile; }); @@ -90,7 +74,7 @@ class InstructionsController { } _instructionsShown[key.toString()] = true; - if (wereInstructionsTurnedOff(key.toString())) { + if (key.toggledOff(context)) { return; } if (L10n.of(context) == null) { @@ -142,31 +126,6 @@ class InstructionsController { ), ); } - - /// Returns a widget that will be added to existing widget - /// which displays hint text defined in the enum extension - Widget getInstructionInlineTooltip( - BuildContext context, - InlineInstructions key, - VoidCallback onClose, - ) { - if (wereInstructionsTurnedOff(key.toString())) { - return const SizedBox(); - } - - if (L10n.of(context) == null) { - ErrorHandler.logError( - m: "null context in ITBotButton.showCard", - s: StackTrace.current, - ); - return const SizedBox(); - } - - return InlineTooltip( - body: InlineInstructions.speechToText.body(context), - onClose: onClose, - ); - } } /// User can toggle on to prevent Instruction Card @@ -196,12 +155,10 @@ class InstructionsToggleState extends State { return SwitchListTile.adaptive( activeColor: AppConfig.activeToggleColor, title: Text(L10n.of(context)!.doNotShowAgain), - value: pangeaController.instructions.wereInstructionsTurnedOff( - widget.instructionsKey.toString(), - ), + value: widget.instructionsKey.toggledOff(context), onChanged: ((value) async { - pangeaController.instructions.updateEnableInstructions( - widget.instructionsKey.toString(), + pangeaController.instructions.setToggledOff( + widget.instructionsKey, value, ); setState(() {}); diff --git a/lib/pangea/utils/match_copy.dart b/lib/pangea/utils/match_copy.dart index 86d784356..97d89bba4 100644 --- a/lib/pangea/utils/match_copy.dart +++ b/lib/pangea/utils/match_copy.dart @@ -91,7 +91,7 @@ class MatchCopy { } final String afterColon = splits.join(); - print("grammar rule ${match.match.rule!.id}"); + debugPrint("grammar rule ${match.match.rule!.id}"); switch (afterColon) { case MatchRuleIds.interactiveTranslation: diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 02b637ee1..e9cf67dd7 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -1,6 +1,7 @@ import 'package:fluffychat/pages/chat/events/audio_player.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_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:flutter/material.dart'; @@ -9,10 +10,12 @@ import 'package:matrix/matrix.dart'; class MessageAudioCard extends StatefulWidget { final PangeaMessageEvent messageEvent; + final MessageOverlayController overlayController; const MessageAudioCard({ super.key, required this.messageEvent, + required this.overlayController, }); @override @@ -70,6 +73,12 @@ class MessageAudioCardState extends State { @override void initState() { super.initState(); + + //once we have audio for words, we'll play that + if (widget.overlayController.isSelection) { + widget.overlayController.clearSelection(); + } + fetchAudio(); } @@ -80,20 +89,15 @@ class MessageAudioCardState extends State { child: _isLoading ? const ToolbarContentLoadingIndicator() : localAudioEvent != null || audioFile != null - ? Container( - constraints: const BoxConstraints( - maxWidth: 250, - ), - child: Column( - children: [ - AudioPlayerWidget( - localAudioEvent, - color: Theme.of(context).colorScheme.onPrimaryContainer, - matrixFile: audioFile, - autoplay: true, - ), - ], - ), + ? Column( + children: [ + AudioPlayerWidget( + localAudioEvent, + color: Theme.of(context).colorScheme.onPrimaryContainer, + matrixFile: audioFile, + autoplay: true, + ), + ], ) : const CardErrorWidget(), ); diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 34ea42ffc..d94790925 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -87,7 +87,6 @@ class MessageOverlayController extends State /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { - debugPrint('Exiting practice flow'); clearSelection(); activitiesLeftToComplete = 0; setState(() {}); diff --git a/lib/pangea/widgets/chat/message_speech_to_text_card.dart b/lib/pangea/widgets/chat/message_speech_to_text_card.dart index 3c06a45f3..b80007897 100644 --- a/lib/pangea/widgets/chat/message_speech_to_text_card.dart +++ b/lib/pangea/widgets/chat/message_speech_to_text_card.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/enum/instructions_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/utils/inline_tooltip.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/common/icon_number_widget.dart'; import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; @@ -66,13 +67,6 @@ class MessageSpeechToTextCardState extends State { } void closeHint() { - MatrixState.pangeaController.instructions.turnOffInstruction( - InlineInstructions.speechToText.toString(), - ); - MatrixState.pangeaController.instructions.updateEnableInstructions( - InlineInstructions.speechToText.toString(), - true, - ); setState(() {}); } @@ -183,33 +177,18 @@ class MessageSpeechToTextCardState extends State { number: "${selectedToken?.confidence ?? speechToTextResponse!.transcript.confidence}%", toolTip: L10n.of(context)!.accuracy, - onPressed: () => MatrixState.pangeaController.instructions - .showInstructionsPopup( - context, - InstructionsEnum.tooltipInstructions, - widget.messageEvent.eventId, - true, - ), ), IconNumberWidget( icon: Icons.speed, number: wordsPerMinuteString != null ? "$wordsPerMinuteString" : "??", toolTip: L10n.of(context)!.wordsPerMinute, - onPressed: () => MatrixState.pangeaController.instructions - .showInstructionsPopup( - context, - InstructionsEnum.tooltipInstructions, - widget.messageEvent.eventId, - true, - ), ), ], ), - MatrixState.pangeaController.instructions.getInstructionInlineTooltip( - context, - InlineInstructions.speechToText, - closeHint, + InlineTooltip( + instructionsEnum: InstructionsEnum.speechToText, + onClose: () => setState(() => {}), ), ], ); diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index f204aec47..63a202f8e 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -43,9 +43,7 @@ class MessageToolbarState extends State { if (!subscribed) { return MessageUnsubscribedCard( - languageTool: widget.overLayController.toolbarMode.title(context), - mode: widget.overLayController.toolbarMode, - controller: this, + controller: widget.overLayController, ); } @@ -58,6 +56,7 @@ class MessageToolbarState extends State { case MessageMode.textToSpeech: return MessageAudioCard( messageEvent: widget.pangeaMessageEvent, + overlayController: widget.overLayController, ); case MessageMode.speechToText: return MessageSpeechToTextCard( diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index 081163558..9c648fa5b 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -32,14 +32,13 @@ class MessageTranslationCardState extends State { @override void initState() { - print('MessageTranslationCard initState'); + debugPrint('MessageTranslationCard initState'); super.initState(); loadTranslation(); } @override void didUpdateWidget(covariant MessageTranslationCard oldWidget) { - // debugger(when: kDebugMode); if (oldWidget.selection != widget.selection) { debugPrint('selection changed'); loadTranslation(); @@ -48,7 +47,6 @@ class MessageTranslationCardState extends State { } Future fetchRepresentationText() async { - // debugger(when: kDebugMode); if (l1Code == null) return; repEvent = widget.messageEvent @@ -114,36 +112,21 @@ class MessageTranslationCardState extends State { String? get l2Code => MatrixState.pangeaController.languageController.activeL2Code(); - void closeHint() { - MatrixState.pangeaController.instructions.turnOffInstruction( - InlineInstructions.l1Translation.toString(), - ); - MatrixState.pangeaController.instructions.updateEnableInstructions( - InlineInstructions.l1Translation.toString(), - true, - ); - setState(() {}); - } - /// Show warning if message's language code is user's L1 /// or if translated text is same as original text. /// Warning does not show if was previously closed - bool get showWarning { - if (MatrixState.pangeaController.instructions.wereInstructionsTurnedOff( - InlineInstructions.l1Translation.toString(), - )) return false; - + bool get notGoingToTranslate { final bool isWrittenInL1 = l1Code != null && widget.messageEvent.originalSent?.langCode == l1Code; final bool isTextIdentical = selectionTranslation != null && widget.messageEvent.originalSent?.text == selectionTranslation; - return (isWrittenInL1 || isTextIdentical) && widget.messageEvent.ownMessage; + return (isWrittenInL1 || isTextIdentical); } @override Widget build(BuildContext context) { - print('MessageTranslationCard build'); + debugPrint('MessageTranslationCard build'); if (!_fetchingTranslation && repEvent == null && selectionTranslation == null) { @@ -165,11 +148,15 @@ class MessageTranslationCardState extends State { repEvent!.text, style: BotStyle.text(context), ), - const SizedBox(height: 10), - if (showWarning) + if (notGoingToTranslate && widget.selection == null) InlineTooltip( - body: InlineInstructions.l1Translation.body(context), - onClose: closeHint, + instructionsEnum: InstructionsEnum.l1Translation, + onClose: () => setState(() {}), + ), + if (widget.selection != null) + InlineTooltip( + instructionsEnum: InstructionsEnum.clickAgainToDeselect, + onClose: () => setState(() {}), ), // if (widget.selection != null) ], diff --git a/lib/pangea/widgets/chat/message_unsubscribed_card.dart b/lib/pangea/widgets/chat/message_unsubscribed_card.dart index 00b9c352d..5363915c3 100644 --- a/lib/pangea/widgets/chat/message_unsubscribed_card.dart +++ b/lib/pangea/widgets/chat/message_unsubscribed_card.dart @@ -1,21 +1,15 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import '../../enum/message_mode_enum.dart'; - class MessageUnsubscribedCard extends StatelessWidget { - final String languageTool; - final MessageMode mode; - final MessageToolbarState controller; + final MessageOverlayController controller; const MessageUnsubscribedCard({ super.key, - required this.languageTool, - required this.mode, required this.controller, }); @@ -24,42 +18,52 @@ class MessageUnsubscribedCard extends StatelessWidget { final bool inTrialWindow = MatrixState.pangeaController.userController.inTrialWindow; - void onButtonPress() { - if (inTrialWindow) { - MatrixState.pangeaController.subscriptionController - .activateNewUserTrial(); - controller.widget.overLayController.updateToolbarMode(mode); - } else { - MatrixState.pangeaController.subscriptionController - .showPaywall(context); - } - } - - return Column( - children: [ - Text( - style: BotStyle.text(context), - "${L10n.of(context)!.subscribedToUnlockTools} $languageTool", - textAlign: TextAlign.center, - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: TextButton( - onPressed: onButtonPress, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - (AppConfig.primaryColor).withOpacity(0.1), + return Container( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Text( + style: BotStyle.text(context), + L10n.of(context)!.subscribedToUnlockTools, + textAlign: TextAlign.center, + ), + if (inTrialWindow) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + MatrixState.pangeaController.subscriptionController + .activateNewUserTrial(); + controller.updateToolbarMode(controller.toolbarMode); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + (AppConfig.primaryColor).withOpacity(0.1), + ), + ), + child: Text(L10n.of(context)!.activateTrial), ), ), - child: Text( - inTrialWindow - ? L10n.of(context)!.activateTrial - : L10n.of(context)!.getAccess, + ], + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + MatrixState.pangeaController.subscriptionController + .showPaywall(context); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + (AppConfig.primaryColor).withOpacity(0.1), + ), + ), + child: Text(L10n.of(context)!.getAccess), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/pangea/widgets/chat/overlay_message_text.dart b/lib/pangea/widgets/chat/overlay_message_text.dart index b99028037..4d91ed359 100644 --- a/lib/pangea/widgets/chat/overlay_message_text.dart +++ b/lib/pangea/widgets/chat/overlay_message_text.dart @@ -98,7 +98,7 @@ class OverlayMessageTextState extends State { return TextSpan( recognizer: TapGestureRecognizer() ..onTap = () { - print( + debugPrint( 'tokenPosition.tokenIndex: ${tokenPosition.tokenIndex}', ); widget.overlayController.onClickOverlayMessageToken( diff --git a/lib/pangea/widgets/common/icon_number_widget.dart b/lib/pangea/widgets/common/icon_number_widget.dart index f677ea579..24307112c 100644 --- a/lib/pangea/widgets/common/icon_number_widget.dart +++ b/lib/pangea/widgets/common/icon_number_widget.dart @@ -6,7 +6,7 @@ class IconNumberWidget extends StatelessWidget { final Color? iconColor; final double? iconSize; final String? toolTip; - final VoidCallback onPressed; + final VoidCallback? onPressed; const IconNumberWidget({ super.key, @@ -15,7 +15,7 @@ class IconNumberWidget extends StatelessWidget { this.toolTip, this.iconColor, this.iconSize, - required this.onPressed, + this.onPressed, }); Widget _content(BuildContext context) { diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index 54ff49544..c35829264 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -411,7 +411,6 @@ class SelectToDefine extends StatelessWidget { return Center( child: Container( height: 80, - width: 200, padding: const EdgeInsets.all(8), child: Center( child: Text( diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 067288157..5ebb4ec38 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -413,10 +413,11 @@ class MessagePracticeActivityCardState extends State { top: 0, right: 0, child: Opacity( - opacity: 0.8, // Slight opacity + opacity: 0.65, // Slight opacity child: Tooltip( message: L10n.of(context)!.reportContentIssueTitle, child: IconButton( + padding: const EdgeInsets.all(2), icon: const Icon(Icons.flag), iconSize: 16, onPressed: () => diff --git a/pubspec.yaml b/pubspec.yaml index 1c8287d72..d9b688f24 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 1.21.4+3537 +version: 1.21.4+3538 environment: sdk: ">=3.0.0 <4.0.0" From 240b039ae702e1d3aac0feac20e6c46f801a8274 Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Sat, 5 Oct 2024 15:51:17 -0400 Subject: [PATCH 16/25] several toolbar UI tweaks --- assets/l10n/intl_en.arb | 4 +- lib/main.dart | 2 +- lib/pangea/choreographer/widgets/it_bar.dart | 4 +- .../controllers/my_analytics_controller.dart | 14 +++ lib/pangea/enum/construct_use_type_enum.dart | 11 +-- .../widgets/chat/message_audio_card.dart | 3 + .../chat/message_selection_overlay.dart | 24 ++++- lib/pangea/widgets/chat/message_toolbar.dart | 3 + .../chat/message_translation_card.dart | 3 + lib/pangea/widgets/content_issue_button.dart | 89 +++++++++++++++++++ lib/pangea/widgets/igc/span_card.dart | 2 + lib/pangea/widgets/igc/word_data_card.dart | 25 +----- .../no_more_practice_card.dart | 11 ++- .../practice_activity_card.dart | 79 +++------------- lib/pangea/widgets/select_to_define.dart | 26 ++++++ 15 files changed, 195 insertions(+), 105 deletions(-) create mode 100644 lib/pangea/widgets/content_issue_button.dart create mode 100644 lib/pangea/widgets/select_to_define.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index e8e2aa585..14627f227 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4231,7 +4231,7 @@ "inviteChat": "📨 Invite chat", "chatName": "Chat name", "reportContentIssueTitle": "Report content issue", - "feedback": "Your feedback (optional)", - "reportContentIssueDescription": "Sorry! AI can make personalized experiences but also may have issues. Please provide any feedback you have and we'll generate a new activity.", + "feedback": "Optional feedback", + "reportContentIssueDescription": "Uh oh! AI can faciliate personalized learning experiences but... also hallucinates. Please provide any feedback you have and we'll try again.", "clickTheWordAgainToDeselect": "Click the selected word to deselect it." } \ 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/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 2fc20531f..9e37112ea 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -225,6 +225,7 @@ class OriginalText extends StatelessWidget { controller.sourceText != null ? Flexible(child: Text(controller.sourceText!)) : const LinearProgressIndicator(), + const SizedBox(width: 4), if (controller.isEditingSourceText) Expanded( child: TextField( @@ -243,7 +244,7 @@ class OriginalText extends StatelessWidget { if (!controller.isEditingSourceText && controller.sourceText != null) AnimatedOpacity( duration: const Duration(milliseconds: 500), - opacity: controller.nextITStep != null ? 1.0 : 0.0, + opacity: controller.nextITStep != null ? 0.7 : 0.0, child: IconButton( onPressed: () => { if (controller.nextITStep != null) @@ -252,6 +253,7 @@ class OriginalText extends StatelessWidget { }, }, icon: const Icon(Icons.edit_outlined), + iconSize: 20, ), ), ], diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index cd6991864..f8fae3457 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -113,6 +113,11 @@ class MyAnalyticsController extends BaseController { _pangeaController.analytics .filterConstructs(unfilteredConstructs: constructs) .then((filtered) { + for (final use in filtered) { + debugPrint( + "_onNewAnalyticsData filtered use: ${use.constructType.string} ${use.useType.string} ${use.lemma} ${use.useType.pointValue}", + ); + } if (filtered.isEmpty) return; // @ggurdin - are we sure this isn't happening twice? it's also above @@ -166,6 +171,14 @@ class MyAnalyticsController extends BaseController { } } + if (kDebugMode) { + for (final use in uses) { + debugPrint( + "Draft use: ${use.constructType.string} ${use.useType.string} ${use.lemma} ${use.useType.pointValue}", + ); + } + } + // @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; @@ -189,6 +202,7 @@ class MyAnalyticsController extends BaseController { /// cache of recently sent messages Future _addLocalMessage( String eventID, + // @ggurdin - why is this an eventID and not a roomID? List constructs, ) async { try { diff --git a/lib/pangea/enum/construct_use_type_enum.dart b/lib/pangea/enum/construct_use_type_enum.dart index c42f7f4a9..ab953d24d 100644 --- a/lib/pangea/enum/construct_use_type_enum.dart +++ b/lib/pangea/enum/construct_use_type_enum.dart @@ -29,13 +29,14 @@ enum ConstructUseTypeEnum { /// encountered as distractor in IGC flow and selected it incIGC, - /// selected correctly in practice activity flow + /// selected correctly in word meaning in context practice activity corPA, - /// encountered as distractor in practice activity flow and correctly ignored it + /// encountered as distractor in word meaning in context practice activity and correctly ignored it + /// Currently not used ignPA, - /// was target construct in practice activity but user did not select correctly + /// was target construct in word meaning in context practice activity and incorrectly selected incPA, } @@ -125,9 +126,9 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.unk: return 0; case ConstructUseTypeEnum.corPA: - return 2; + return 5; case ConstructUseTypeEnum.incPA: - return -1; + return -2; case ConstructUseTypeEnum.ignPA: return 1; } diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index e9cf67dd7..b190da291 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -2,6 +2,7 @@ import 'package:fluffychat/pages/chat/events/audio_player.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_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:flutter/material.dart'; @@ -86,6 +87,8 @@ class MessageAudioCardState extends State { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, child: _isLoading ? const ToolbarContentLoadingIndicator() : localAudioEvent != null || audioFile != null diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index d94790925..5c41a3c33 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:matrix/matrix.dart'; class MessageSelectionOverlay extends StatefulWidget { @@ -73,6 +74,26 @@ class MessageOverlayController extends State setInitialToolbarMode(); } + /// We need to check if the setState call is safe to call immediately + /// Kept getting the error: setState() or markNeedsBuild() called during build. + /// This is a workaround to prevent that error + @override + void setState(VoidCallback fn) { + if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle || + SchedulerBinding.instance.schedulerPhase == + SchedulerPhase.postFrameCallbacks) { + // It's safe to call setState immediately + super.setState(fn); + } else { + // Defer the setState call to after the current frame + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + super.setState(fn); + } + }); + } + } + bool get isPracticeComplete => activitiesLeftToComplete <= 0; /// When an activity is completed, we need to update the state @@ -137,7 +158,8 @@ class MessageOverlayController extends State void onClickOverlayMessageToken( PangeaToken token, ) { - if (toolbarMode == MessageMode.practiceActivity) { + if ([MessageMode.practiceActivity, MessageMode.textToSpeech] + .contains(toolbarMode)) { return; } diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 63a202f8e..f0c0dde80 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -13,10 +13,13 @@ 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/pangea/widgets/select_to_define.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +const double minCardHeight = 70; + class MessageToolbar extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overLayController; diff --git a/lib/pangea/widgets/chat/message_translation_card.dart b/lib/pangea/widgets/chat/message_translation_card.dart index 9c648fa5b..5e66d9966 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -6,6 +6,7 @@ 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_toolbar.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'; @@ -135,6 +136,8 @@ class MessageTranslationCardState extends State { return Container( padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, child: _fetchingTranslation ? const ToolbarContentLoadingIndicator() : Column( diff --git a/lib/pangea/widgets/content_issue_button.dart b/lib/pangea/widgets/content_issue_button.dart new file mode 100644 index 000000000..7df2565c0 --- /dev/null +++ b/lib/pangea/widgets/content_issue_button.dart @@ -0,0 +1,89 @@ +import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class ContentIssueButton extends StatelessWidget { + final bool isActive; + final void Function(String) submitFeedback; + + const ContentIssueButton({ + super.key, + required this.isActive, + required this.submitFeedback, + }); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: 0.8, // Slight opacity + child: Tooltip( + message: L10n.of(context)!.reportContentIssueTitle, + child: IconButton( + icon: const Icon(Icons.flag), + iconSize: 16, + onPressed: () { + if (!isActive) { + return; + } + final TextEditingController feedbackController = + TextEditingController(); + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + L10n.of(context)!.reportContentIssueTitle, + textAlign: TextAlign.center, + ), + content: Container( + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BotFace( + width: 60, + expression: BotExpression.addled, + ), + const SizedBox(height: 10), + Text(L10n.of(context)!.reportContentIssueDescription), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextField( + controller: feedbackController, + decoration: InputDecoration( + labelText: L10n.of(context)!.feedback, + border: const OutlineInputBorder(), + ), + maxLines: 4, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close the dialog + }, + child: Text(L10n.of(context)!.cancel), + ), + ElevatedButton( + onPressed: () { + // Call the additional callback function + submitFeedback(feedbackController.text); + Navigator.of(context).pop(); // Close the dialog + }, + child: Text(L10n.of(context)!.submit), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/igc/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index 97fb2cfe4..757f8f0ea 100644 --- a/lib/pangea/widgets/igc/span_card.dart +++ b/lib/pangea/widgets/igc/span_card.dart @@ -143,6 +143,8 @@ class SpanCardState extends State { } } + /// @ggurdin - this seems like it would be including the correct answer as well + /// we only want to give this kind of points for ignored distractors /// Returns the list of choices that are not selected List? get ignoredMatches => widget.scm.pangeaMatch?.match.choices ?.where((choice) => !choice.selected) diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index c35829264..6f1492a75 100644 --- a/lib/pangea/widgets/igc/word_data_card.dart +++ b/lib/pangea/widgets/igc/word_data_card.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/pangea/models/language_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; @@ -176,6 +177,8 @@ class WordDataCardView extends StatelessWidget { return Container( padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, child: Scrollbar( thumbVisibility: true, controller: scrollController, @@ -400,25 +403,3 @@ class PartOfSpeechBlock extends StatelessWidget { ); } } - -class SelectToDefine extends StatelessWidget { - const SelectToDefine({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - height: 80, - padding: const EdgeInsets.all(8), - child: Center( - child: Text( - L10n.of(context)!.selectToDefine, - style: BotStyle.text(context), - ), - ), - ), - ); - } -} diff --git a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart index 46df44ce5..1cef6c174 100644 --- a/lib/pangea/widgets/practice_activity/no_more_practice_card.dart +++ b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:flutter/material.dart'; class StarAnimationWidget extends StatefulWidget { @@ -42,8 +43,8 @@ class _StarAnimationWidgetState extends State Widget build(BuildContext context) { return SizedBox( // Set constant height and width for the star container - height: 80.0, - width: 80.0, + height: 60.0, + width: 60.0, child: Center( child: AnimatedBuilder( animation: _controller, @@ -74,6 +75,7 @@ class GamifiedTextWidget extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, // Adjusts the size to fit children children: [ + const SizedBox(height: 10), // Spacing between the star and text // Star animation above the text const StarAnimationWidget(), const SizedBox(height: 10), // Spacing between the star and text @@ -84,10 +86,7 @@ class GamifiedTextWidget extends StatelessWidget { padding: const EdgeInsets.all(8), child: Text( userMessage, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: BotStyle.text(context), textAlign: TextAlign.center, // Center-align the text ), ), diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 5ebb4ec38..517cbcebe 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -13,6 +13,8 @@ import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/content_issue_button.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart'; @@ -54,7 +56,7 @@ class MessagePracticeActivityCardState extends State { // Used to show an animation when the user completes an activity // while simultaneously fetching a new activity and not showing the loading spinner // until the appropriate time has passed to 'savor the joy' - Duration appropriateTimeForJoy = const Duration(milliseconds: 1000); + Duration appropriateTimeForJoy = const Duration(milliseconds: 1500); bool savoringTheJoy = false; @override @@ -65,7 +67,7 @@ class MessagePracticeActivityCardState extends State { void _updateFetchingActivity(bool value) { if (fetchingActivity == value) return; - setState(() => fetchingActivity = value); + if (mounted) setState(() => fetchingActivity = value); } void _setPracticeActivity(PracticeActivityEvent? activity) { @@ -177,19 +179,13 @@ class MessagePracticeActivityCardState extends State { ); Future _savorTheJoy() async { - if (savoringTheJoy) { - //should not happen - debugger(when: kDebugMode); - } - savoringTheJoy = true; + debugger(when: savoringTheJoy && kDebugMode); - debugPrint('Savoring the joy'); + setState(() => savoringTheJoy = true); await Future.delayed(appropriateTimeForJoy); - savoringTheJoy = false; - - debugPrint('Savoring the joy is over'); + if (mounted) setState(() => savoringTheJoy = false); } /// Called when the user finishes an activity. @@ -233,6 +229,7 @@ class MessagePracticeActivityCardState extends State { widget.overlayController.onActivityFinish(); + // final Iterable result = await Future.wait([ _savorTheJoy(), _fetchNewActivity(), @@ -254,50 +251,6 @@ class MessagePracticeActivityCardState extends State { } } - void onFlagClick(BuildContext context) { - final TextEditingController feedbackController = TextEditingController(); - - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(L10n.of(context)!.reportContentIssueTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(L10n.of(context)!.reportContentIssueDescription), - const SizedBox(height: 10), - TextField( - controller: feedbackController, - decoration: InputDecoration( - labelText: L10n.of(context)!.feedback, - border: const OutlineInputBorder(), - ), - maxLines: 3, - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); // Close the dialog - }, - child: Text(L10n.of(context)!.cancel), - ), - ElevatedButton( - onPressed: () { - // Call the additional callback function - submitFeedback(feedbackController.text); - Navigator.of(context).pop(); // Close the dialog - }, - child: Text(L10n.of(context)!.submit), - ), - ], - ); - }, - ); - } - /// clear the current activity, record, and selection /// fetch a new activity, including the offending activity in the request void submitFeedback(String feedback) { @@ -385,6 +338,7 @@ class MessagePracticeActivityCardState extends State { constraints: const BoxConstraints( maxWidth: 350, minWidth: 350, + minHeight: minCardHeight, ), child: Stack( alignment: Alignment.center, @@ -412,18 +366,9 @@ class MessagePracticeActivityCardState extends State { Positioned( top: 0, right: 0, - child: Opacity( - opacity: 0.65, // Slight opacity - child: Tooltip( - message: L10n.of(context)!.reportContentIssueTitle, - child: IconButton( - padding: const EdgeInsets.all(2), - icon: const Icon(Icons.flag), - iconSize: 16, - onPressed: () => - currentActivity == null ? null : onFlagClick(context), - ), - ), + child: ContentIssueButton( + isActive: currentActivity != null, + submitFeedback: submitFeedback, ), ), ], diff --git a/lib/pangea/widgets/select_to_define.dart b/lib/pangea/widgets/select_to_define.dart new file mode 100644 index 000000000..7020e5e77 --- /dev/null +++ b/lib/pangea/widgets/select_to_define.dart @@ -0,0 +1,26 @@ +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class SelectToDefine extends StatelessWidget { + const SelectToDefine({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + constraints: const BoxConstraints(minHeight: minCardHeight), + padding: const EdgeInsets.all(8), + child: Center( + child: Text( + L10n.of(context)!.selectToDefine, + style: BotStyle.text(context), + ), + ), + ), + ); + } +} From 1849178da90306bec8c578c974ca49955f1ca17d Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Sun, 6 Oct 2024 17:18:35 -0400 Subject: [PATCH 17/25] ui tweaks for subscribe card and audio card --- assets/l10n/intl_en.arb | 2 +- assets/l10n/intl_es.arb | 1 - .../controllers/igc_controller.dart | 2 +- .../widgets/it_feedback_card.dart | 10 +- .../controllers/subscription_controller.dart | 2 +- .../chat/message_speech_to_text_card.dart | 74 ++++++++------- .../toolbar_content_loading_indicator.dart | 20 ++-- lib/pangea/widgets/igc/card_error_widget.dart | 40 ++++---- lib/pangea/widgets/igc/paywall_card.dart | 95 +++++++++++-------- lib/pangea/widgets/igc/why_button.dart | 4 +- 10 files changed, 142 insertions(+), 108 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 14627f227..9bdf2ea6b 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3135,7 +3135,7 @@ "prettyGood": "Pretty good! Here's what I would have said.", "letMeThink": "Hmm, let's see how you did!", "clickMessageTitle": "Need help?", - "clickMessageBody": "Click a message for language help! Click and hold to react 😀.", + "clickMessageBody": "Click a message for language tools like translation, play back and more!", "understandingMessagesTitle": "Definitions and translations!", "understandingMessagesBody": "Click underlined words for definitions. Translate with message options (upper right).", "allDone": "All done!", diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index 2d374f381..49421500b 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4490,7 +4490,6 @@ "messageAudio": "mensaje de audio", "definitions": "definiciones", "clickMessageTitle": "¿Necesitas ayuda?", - "clickMessageBody": "¡Lame un mensaje para obtener ayuda con el idioma! Haz clic y mantén presionado para reaccionar 😀", "more": "Más", "translationTooltip": "Traducir", "audioTooltip": "Reproducir audio", diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index aae7104d4..4beea095f 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -124,7 +124,7 @@ class IgcController { ), roomId: choreographer.roomId, ), - cardSize: match.isITStart ? const Size(350, 260) : const Size(350, 400), + cardSize: match.isITStart ? const Size(350, 260) : const Size(400, 400), transformTargetId: choreographer.inputTransformTargetKey, ); } diff --git a/lib/pangea/choreographer/widgets/it_feedback_card.dart b/lib/pangea/choreographer/widgets/it_feedback_card.dart index 06f2493d1..dd72f2457 100644 --- a/lib/pangea/choreographer/widgets/it_feedback_card.dart +++ b/lib/pangea/choreographer/widgets/it_feedback_card.dart @@ -131,11 +131,11 @@ class ITFeedbackCardView extends StatelessWidget { text: controller.widget.req.chosenContinuance, botExpression: BotExpression.nonGold, ), - Text( - controller.widget.choiceFeedback, - style: BotStyle.text(context), - ), - const SizedBox(height: 20), + // Text( + // controller.widget.choiceFeedback, + // style: BotStyle.text(context), + // ), + const SizedBox(height: 10), if (controller.res == null) WhyButton( onPress: controller.handleGetExplanationButtonPress, diff --git a/lib/pangea/controllers/subscription_controller.dart b/lib/pangea/controllers/subscription_controller.dart index d90687ae1..73cf77dff 100644 --- a/lib/pangea/controllers/subscription_controller.dart +++ b/lib/pangea/controllers/subscription_controller.dart @@ -254,7 +254,7 @@ class SubscriptionController extends BaseController { !isSubscribed && (_lastDismissedPaywall == null || DateTime.now().difference(_lastDismissedPaywall!).inHours > - (24 * (_paywallBackoff ?? 1))); + (1 * (_paywallBackoff ?? 1))); } void dismissPaywall() async { diff --git a/lib/pangea/widgets/chat/message_speech_to_text_card.dart b/lib/pangea/widgets/chat/message_speech_to_text_card.dart index b80007897..012647b5a 100644 --- a/lib/pangea/widgets/chat/message_speech_to_text_card.dart +++ b/lib/pangea/widgets/chat/message_speech_to_text_card.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/inline_tooltip.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/common/icon_number_widget.dart'; import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; @@ -158,39 +159,46 @@ class MessageSpeechToTextCardState extends State { final int total = words * accuracy; //TODO: find better icons - return Column( - children: [ - RichText( - text: _buildTranscriptText(context), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - // IconNumberWidget( - // icon: Icons.abc, - // number: (selectedToken == null ? words : 1).toString(), - // toolTip: L10n.of(context)!.words, - // ), - IconNumberWidget( - icon: Symbols.target, - number: - "${selectedToken?.confidence ?? speechToTextResponse!.transcript.confidence}%", - toolTip: L10n.of(context)!.accuracy, - ), - IconNumberWidget( - icon: Icons.speed, - number: - wordsPerMinuteString != null ? "$wordsPerMinuteString" : "??", - toolTip: L10n.of(context)!.wordsPerMinute, - ), - ], - ), - InlineTooltip( - instructionsEnum: InstructionsEnum.speechToText, - onClose: () => setState(() => {}), - ), - ], + return Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, + child: Column( + children: [ + const SizedBox(height: 8), + RichText( + text: _buildTranscriptText(context), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // IconNumberWidget( + // icon: Icons.abc, + // number: (selectedToken == null ? words : 1).toString(), + // toolTip: L10n.of(context)!.words, + // ), + IconNumberWidget( + icon: Symbols.target, + number: + "${selectedToken?.confidence ?? speechToTextResponse!.transcript.confidence}%", + toolTip: L10n.of(context)!.accuracy, + ), + IconNumberWidget( + icon: Icons.speed, + number: wordsPerMinuteString != null + ? "$wordsPerMinuteString" + : "??", + toolTip: L10n.of(context)!.wordsPerMinute, + ), + ], + ), + InlineTooltip( + instructionsEnum: InstructionsEnum.speechToText, + onClose: () => setState(() => {}), + ), + ], + ), ); } } diff --git a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart index 9edc9971d..f61496013 100644 --- a/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart +++ b/lib/pangea/widgets/chat/toolbar_content_loading_indicator.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:flutter/material.dart'; class ToolbarContentLoadingIndicator extends StatelessWidget { @@ -7,13 +8,18 @@ class ToolbarContentLoadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: SizedBox( - height: 14, - width: 14, - child: CircularProgressIndicator( - strokeWidth: 2.0, - color: Theme.of(context).colorScheme.primary, + return Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, + child: Center( + child: SizedBox( + height: 14, + width: 14, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: Theme.of(context).colorScheme.primary, + ), ), ), ); diff --git a/lib/pangea/widgets/igc/card_error_widget.dart b/lib/pangea/widgets/igc/card_error_widget.dart index 7b3385c68..3f08f6277 100644 --- a/lib/pangea/widgets/igc/card_error_widget.dart +++ b/lib/pangea/widgets/igc/card_error_widget.dart @@ -1,6 +1,7 @@ import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/common/bot_face_svg.dart'; import 'package:fluffychat/pangea/widgets/igc/card_header.dart'; import 'package:flutter/material.dart'; @@ -20,25 +21,30 @@ class CardErrorWidget extends StatelessWidget { Widget build(BuildContext context) { final ErrorCopy errorCopy = ErrorCopy(context, error); - return SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CardHeader( - text: errorCopy.title, - botExpression: BotExpression.addled, - onClose: () => choreographer?.onMatchError( - cursorOffset: offset, + return Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CardHeader( + text: errorCopy.title, + botExpression: BotExpression.addled, + onClose: () => choreographer?.onMatchError( + cursorOffset: offset, + ), ), - ), - const SizedBox(height: 10.0), - Center( - child: Text( - errorCopy.body, - style: BotStyle.text(context), + const SizedBox(height: 10.0), + Center( + child: Text( + errorCopy.body, + style: BotStyle.text(context), + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pangea/widgets/igc/paywall_card.dart b/lib/pangea/widgets/igc/paywall_card.dart index 78f7b6985..a2c35ff86 100644 --- a/lib/pangea/widgets/igc/paywall_card.dart +++ b/lib/pangea/widgets/igc/paywall_card.dart @@ -21,69 +21,84 @@ class PaywallCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ CardHeader( - text: L10n.of(context)!.subscriptionPopupTitle, + text: L10n.of(context)!.clickMessageTitle, botExpression: BotExpression.addled, + onClose: () { + MatrixState.pangeaController.subscriptionController + .dismissPaywall(); + }, ), Padding( - padding: const EdgeInsets.all(17), + padding: const EdgeInsets.all(8), child: Column( mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Text( - L10n.of(context)!.subscriptionPopupDesc, + L10n.of(context)!.subscribedToUnlockTools, style: BotStyle.text(context), textAlign: TextAlign.center, ), - if (inTrialWindow) - Text( - L10n.of(context)!.noPaymentInfo, - style: BotStyle.text(context), - textAlign: TextAlign.center, + // if (inTrialWindow) + // Text( + // L10n.of(context)!.noPaymentInfo, + // style: BotStyle.text(context), + // textAlign: TextAlign.center, + // ), + if (inTrialWindow) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + MatrixState.pangeaController.subscriptionController + .activateNewUserTrial(); + MatrixState.pAnyState.closeOverlay(); + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + (AppConfig.primaryColor).withOpacity(0.1), + ), + ), + child: Text(L10n.of(context)!.activateTrial), + ), ), - const SizedBox(height: 15.0), + ], + const SizedBox(height: 10), SizedBox( width: double.infinity, child: TextButton( onPressed: () { - inTrialWindow - ? MatrixState.pangeaController.subscriptionController - .activateNewUserTrial() - : MatrixState.pangeaController.subscriptionController - .showPaywall(context); - MatrixState.pAnyState.closeOverlay(); + MatrixState.pangeaController.subscriptionController + .showPaywall(context); }, style: ButtonStyle( backgroundColor: WidgetStateProperty.all( (AppConfig.primaryColor).withOpacity(0.1), ), ), - child: Text( - inTrialWindow - ? L10n.of(context)!.activateTrial - : L10n.of(context)!.seeOptions, - ), - ), - ), - const SizedBox(height: 5.0), - SizedBox( - width: double.infinity, - child: TextButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - AppConfig.primaryColor.withOpacity(0.1), - ), - ), - onPressed: () { - MatrixState.pangeaController.subscriptionController - .dismissPaywall(); - MatrixState.pAnyState.closeOverlay(); - }, - child: Center( - child: Text(L10n.of(context)!.continuedWithoutSubscription), - ), + child: Text(L10n.of(context)!.getAccess), ), ), + // const SizedBox(height: 5.0), + // SizedBox( + // width: double.infinity, + // child: TextButton( + // style: ButtonStyle( + // backgroundColor: WidgetStateProperty.all( + // AppConfig.primaryColor.withOpacity(0.1), + // ), + // ), + // onPressed: () { + // MatrixState.pangeaController.subscriptionController + // .dismissPaywall(); + // MatrixState.pAnyState.closeOverlay(); + // }, + // child: Center( + // child: Text(L10n.of(context)!.continuedWithoutSubscription), + // ), + // ), + // ), ], ), ), diff --git a/lib/pangea/widgets/igc/why_button.dart b/lib/pangea/widgets/igc/why_button.dart index 7cb367065..c9184f120 100644 --- a/lib/pangea/widgets/igc/why_button.dart +++ b/lib/pangea/widgets/igc/why_button.dart @@ -18,10 +18,10 @@ class WhyButton extends StatelessWidget { return TextButton( onPressed: loading ? null : onPress, style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( + backgroundColor: WidgetStateProperty.all( AppConfig.primaryColor.withOpacity(0.1), ), - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), // Border radius side: const BorderSide( From 310073dbba8897a2a7e9df6ddcd5803e5483d3cf Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Sun, 6 Oct 2024 17:22:09 -0400 Subject: [PATCH 18/25] increment version number --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index d9b688f24..d15833a5a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 1.21.4+3538 +version: 1.21.4+3539 environment: sdk: ">=3.0.0 <4.0.0" From a4ced9ab65c728e26fd65bd1deb768099f787bd7 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 7 Oct 2024 09:45:20 -0400 Subject: [PATCH 19/25] fix .env file path in main.dart --- 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 879a52c81f25e791ba62b7b7528c1f6dc8a71068 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 7 Oct 2024 14:48:14 -0400 Subject: [PATCH 20/25] responded to comment from Will in client code, added fix for mini analytics view level display --- .../controllers/get_analytics_controller.dart | 27 ++++++++++++-- .../controllers/my_analytics_controller.dart | 29 ++++++++------- .../models/analytics/constructs_model.dart | 4 +++ lib/pangea/models/choreo_record.dart | 1 - .../animations/progress_bar/level_bar.dart | 11 ++---- .../progress_bar/progress_bar_details.dart | 2 ++ .../chat/message_selection_overlay.dart | 1 - .../widgets/chat/message_toolbar_buttons.dart | 36 ++++++------------- .../learning_progress_indicators.dart | 28 +++++++++------ lib/pangea/widgets/igc/span_card.dart | 6 ++-- 10 files changed, 80 insertions(+), 65 deletions(-) diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index 9a6a934a1..03cb2d60b 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/local.key.dart'; @@ -42,8 +43,30 @@ class GetAnalyticsController { /// Get the current level based on the number of xp points /// 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; + int get level => 1 + sqrt((1 + 8 * currentXP / 100) / 2).floor(); + + // the minimum XP required for a given level + double get minXPForLevel { + return 12.5 * (2 * pow(level - 1, 2) - 1); + } + + // the minimum XP required for the next level + double get minXPForNextLevel { + return 12.5 * (2 * pow(level, 2) - 1); + } + + // the progress within the current level as a percentage (0.0 to 1.0) + double get levelProgress { + final progress = + (currentXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel); + return progress >= 0 ? progress : 0; + } + + double get serverLevelProgress { + final progress = + (serverXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel); + return progress >= 0 ? progress : 0; + } void initialize() { _analyticsUpdateSubscription ??= _pangeaController diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index f8fae3457..77e6caf27 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -42,7 +42,7 @@ class MyAnalyticsController extends BaseController { final int _maxMessagesCached = 10; /// the number of minutes before an automatic update is triggered - final int _minutesBeforeUpdate = 2; + final int _minutesBeforeUpdate = 5; /// the time since the last update that will trigger an automatic update final Duration _timeSinceUpdate = const Duration(days: 1); @@ -120,9 +120,6 @@ class MyAnalyticsController extends BaseController { } if (filtered.isEmpty) return; - // @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( @@ -179,8 +176,6 @@ 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( (_) => _decideWhetherToUpdateAnalyticsRoom(level), @@ -201,21 +196,20 @@ class MyAnalyticsController extends BaseController { /// Add a list of construct uses for a new message to the local /// cache of recently sent messages Future _addLocalMessage( - String eventID, - // @ggurdin - why is this an eventID and not a roomID? + String cacheKey, List constructs, ) async { try { final currentCache = _pangeaController.analytics.messagesSinceUpdate; - constructs.addAll(currentCache[eventID] ?? []); - currentCache[eventID] = constructs; + constructs.addAll(currentCache[cacheKey] ?? []); + currentCache[cacheKey] = constructs; await _setMessagesSinceUpdate(currentCache); } catch (e, s) { ErrorHandler.logError( e: PangeaWarningError("Failed to add message since update: $e"), s: s, - m: 'Failed to add message since update for eventId: $eventID', + m: 'Failed to add message since update for eventId: $cacheKey', ); } } @@ -248,7 +242,18 @@ class MyAnalyticsController extends BaseController { /// Clears the local cache of recently sent constructs. Called before updating analytics void clearMessagesSinceUpdate() { - _pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate); + final localCache = _pangeaController.analytics.messagesSinceUpdate; + final draftKeys = localCache.keys.where((key) => key.startsWith('draft')); + if (draftKeys.isEmpty) { + _pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate); + return; + } + + final Map> newCache = {}; + for (final key in draftKeys) { + newCache[key] = localCache[key]!; + } + _setMessagesSinceUpdate(newCache); } /// Save the local cache of recently sent constructs to the local storage diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index a1495a276..10a47516a 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -78,6 +78,10 @@ class OneConstructUse { List categories; ConstructTypeEnum constructType; ConstructUseTypeEnum useType; + + /// Used to unqiuely identify the construct use. Useful in the case + /// that a users makes the same type of mistake multiple times in a + /// message, and those uses need to be disinguished. String? id; ConstructUseMetaData metadata; diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index ace5a738e..fe95dfc09 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -145,7 +145,6 @@ 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/widgets/animations/progress_bar/level_bar.dart b/lib/pangea/widgets/animations/progress_bar/level_bar.dart index fb57a3bd5..fb8461f43 100644 --- a/lib/pangea/widgets/animations/progress_bar/level_bar.dart +++ b/lib/pangea/widgets/animations/progress_bar/level_bar.dart @@ -1,5 +1,4 @@ import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/widgets/animations/progress_bar/animated_level_dart.dart'; import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart'; import 'package:flutter/material.dart'; @@ -21,17 +20,11 @@ class LevelBar extends StatefulWidget { class LevelBarState extends State { double prevWidth = 0; - double get width { - const perLevel = AnalyticsConstants.xpPerLevel; - final percent = (widget.details.currentPoints % perLevel) / perLevel; - return widget.progressBarDetails.totalWidth * percent; - } - @override void didUpdateWidget(covariant LevelBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.details.currentPoints != widget.details.currentPoints) { - setState(() => prevWidth = width); + setState(() => prevWidth = widget.details.width); } } @@ -40,7 +33,7 @@ class LevelBarState extends State { return AnimatedLevelBar( height: widget.progressBarDetails.height, beginWidth: prevWidth, - endWidth: width, + endWidth: widget.details.width, decoration: BoxDecoration( borderRadius: const BorderRadius.all( Radius.circular(AppConfig.borderRadius), diff --git a/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart b/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart index debe93816..9ff4df142 100644 --- a/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart +++ b/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart @@ -3,10 +3,12 @@ import 'dart:ui'; class LevelBarDetails { final Color fillColor; final int currentPoints; + final double width; const LevelBarDetails({ required this.fillColor, required this.currentPoints, + required this.width, }); } diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 5c41a3c33..01c44955f 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -348,7 +348,6 @@ class MessageOverlayController extends State nextEvent: widget._nextEvent, previousEvent: widget._prevEvent, ), - // TODO for @ggurdin - move reactions and toolbar here // MessageReactions(widget._event, widget.chatController.timeline!), // const SizedBox(height: 6), // MessagePadding( diff --git a/lib/pangea/widgets/chat/message_toolbar_buttons.dart b/lib/pangea/widgets/chat/message_toolbar_buttons.dart index 564538a75..c0d021caf 100644 --- a/lib/pangea/widgets/chat/message_toolbar_buttons.dart +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -8,7 +8,7 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:flutter/material.dart'; -class ToolbarButtons extends StatefulWidget { +class ToolbarButtons extends StatelessWidget { final MessageOverlayController overlayController; final double width; @@ -18,13 +18,8 @@ class ToolbarButtons extends StatefulWidget { super.key, }); - @override - ToolbarButtonsState createState() => ToolbarButtonsState(); -} - -class ToolbarButtonsState extends State { PangeaMessageEvent get pangeaMessageEvent => - widget.overlayController.pangeaMessageEvent; + overlayController.pangeaMessageEvent; List get modes => MessageMode.values .where((mode) => mode.isValidMode(pangeaMessageEvent.event)) @@ -32,31 +27,23 @@ class ToolbarButtonsState extends State { static const double iconWidth = 36.0; - MessageOverlayController get overlayController => widget.overlayController; - - // @ggurdin - maybe this can be stateless now? - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { - final double barWidth = widget.width - iconWidth; + final double barWidth = width - iconWidth; - if (widget.overlayController.pangeaMessageEvent.isAudioMessage) { + if (overlayController.pangeaMessageEvent.isAudioMessage) { return const SizedBox(); } return SizedBox( - width: widget.width, + width: width, child: Stack( alignment: Alignment.center, children: [ Stack( children: [ Container( - width: widget.width, + width: width, height: 12, decoration: BoxDecoration( color: MessageModeExtension.barAndLockedButtonColor(context), @@ -87,18 +74,18 @@ class ToolbarButtonsState extends State { child: IconButton( iconSize: 20, icon: Icon(mode.icon), - color: mode == widget.overlayController.toolbarMode + color: mode == overlayController.toolbarMode ? Colors.white : null, - isSelected: mode == widget.overlayController.toolbarMode, + isSelected: mode == overlayController.toolbarMode, style: ButtonStyle( backgroundColor: WidgetStateProperty.all( mode.iconButtonColor( context, index, - widget.overlayController.toolbarMode, + overlayController.toolbarMode, pangeaMessageEvent.numberOfActivitiesCompleted, - widget.overlayController.isPracticeComplete, + overlayController.isPracticeComplete, ), ), ), @@ -107,8 +94,7 @@ class ToolbarButtonsState extends State { pangeaMessageEvent.numberOfActivitiesCompleted, overlayController.isPracticeComplete, ) - ? () => - widget.overlayController.updateToolbarMode(mode) + ? () => overlayController.updateToolbarMode(mode) : null, ), ), 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 5c1c3337f..6695d2673 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 @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; @@ -60,7 +59,7 @@ class LearningProgressIndicatorsState _pangeaController.analytics.locallyCachedConstructs, ); int get serverXP => currentXP - localXP; - int get level => currentXP ~/ AnalyticsConstants.xpPerLevel; + int get level => _pangeaController.analytics.level; @override void initState() { @@ -147,10 +146,13 @@ class LearningProgressIndicatorsState ? const Color.fromARGB(255, 0, 190, 83) : Theme.of(context).colorScheme.primary, currentPoints: currentXP, + width: levelBarWidth * _pangeaController.analytics.levelProgress, ), LevelBarDetails( fillColor: Theme.of(context).colorScheme.primary, currentPoints: serverXP, + width: + levelBarWidth * _pangeaController.analytics.serverLevelProgress, ), ], progressBarDetails: ProgressBarDetails( @@ -242,15 +244,19 @@ class LearningProgressIndicatorsState ], ), ), - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Stack( - alignment: Alignment.center, - children: [ - Positioned(left: 16, right: 0, child: progressBar), - Positioned(left: 0, child: levelBadge), - ], + Center( + child: SizedBox( + height: 36, + child: SizedBox( + width: levelBarWidth + 16, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned(left: 16, right: 0, child: progressBar), + Positioned(left: 0, child: levelBadge), + ], + ), + ), ), ), const SizedBox(height: 16), diff --git a/lib/pangea/widgets/igc/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index 757f8f0ea..816e8ed15 100644 --- a/lib/pangea/widgets/igc/span_card.dart +++ b/lib/pangea/widgets/igc/span_card.dart @@ -143,11 +143,9 @@ class SpanCardState extends State { } } - /// @ggurdin - this seems like it would be including the correct answer as well - /// we only want to give this kind of points for ignored distractors - /// Returns the list of choices that are not selected + /// Returns the list of distractor choices that are not selected List? get ignoredMatches => widget.scm.pangeaMatch?.match.choices - ?.where((choice) => !choice.selected) + ?.where((choice) => choice.isDistractor && !choice.selected) .toList(); /// Returns the list of tokens from choices that are not selected From cd9792fb1eb48a9b5283b02a7b122f687841d23f Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Mon, 7 Oct 2024 15:42:51 -0400 Subject: [PATCH 21/25] get new tokens in representation if they don't match text --- .../pangea_representation_event.dart | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart index 970d88da8..172f665f4 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_representation_event.dart @@ -98,7 +98,22 @@ class RepresentationEvent { ); } - _tokens = tokenEvents.first.getPangeaContent(); + final PangeaMessageTokens storedTokens = + tokenEvents.first.getPangeaContent(); + + if (PangeaToken.reconstructText(storedTokens.tokens) != text) { + ErrorHandler.logError( + m: 'Stored tokens do not match text for representation', + s: StackTrace.current, + data: { + 'text': text, + 'tokens': storedTokens.tokens, + }, + ); + return null; + } + + _tokens = storedTokens; return _tokens?.tokens; } From 0a7a9f3d7c51aa9be673a5b9d2a2d86f29672553 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 7 Oct 2024 16:51:29 -0400 Subject: [PATCH 22/25] update github action to run on push to main branch --- .github/workflows/main_deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main_deploy.yaml b/.github/workflows/main_deploy.yaml index 8256afff7..a697f9207 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -3,7 +3,7 @@ name: Main Deploy Workflow on: push: branches: - - development + - main workflow_dispatch: env: From 5540efc818f007062de2749bdf23c501ca070d82 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 7 Oct 2024 17:04:51 -0400 Subject: [PATCH 23/25] fixes for overlay message positioning, moved toolbar buttons underneath overlay message --- .../chat/message_selection_overlay.dart | 112 +++++++++-------- lib/pangea/widgets/chat/message_toolbar.dart | 18 +-- .../widgets/chat/message_toolbar_buttons.dart | 1 + lib/pangea/widgets/chat/overlay_message.dart | 117 ++++++++++++++++++ 4 files changed, 183 insertions(+), 65 deletions(-) create mode 100644 lib/pangea/widgets/chat/overlay_message.dart diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 01c44955f..dbde10f52 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -4,14 +4,15 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pages/chat/events/message.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; +import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; @@ -225,10 +226,10 @@ class MessageOverlayController extends State final headerBottomOffset = screenHeight - headerHeight; final footerBottomOffset = footerHeight; final currentBottomOffset = - screenHeight - messageOffset!.dy - messageSize!.height; + screenHeight - messageOffset!.dy - messageSize!.height - 50; final bool hasHeaderOverflow = - messageOffset!.dy < (AppConfig.toolbarMaxHeight + headerHeight); + (messageOffset!.dy - 50) < (AppConfig.toolbarMaxHeight + headerHeight); final bool hasFooterOverflow = footerHeight > currentBottomOffset; if (!hasHeaderOverflow && !hasFooterOverflow) return; @@ -241,7 +242,7 @@ class MessageOverlayController extends State // if the overlay would have a footer overflow for this message, // check if shifting the overlay up could cause a header overflow final bottomOffsetDifference = footerHeight - currentBottomOffset; - final newTopOffset = messageOffset!.dy - bottomOffsetDifference; + final newTopOffset = messageOffset!.dy - bottomOffsetDifference - 50; final bool upshiftCausesHeaderOverflow = hasFooterOverflow && newTopOffset < (headerHeight + AppConfig.toolbarMaxHeight); @@ -301,6 +302,8 @@ class MessageOverlayController extends State double get screenHeight => MediaQuery.of(context).size.height; + double get screenWidth => MediaQuery.of(context).size.width; + @override Widget build(BuildContext context) { final bool showDetails = (Matrix.of(context) @@ -310,7 +313,22 @@ class MessageOverlayController extends State FluffyThemes.isThreeColumnMode(context) && widget.chatController.room.membership == Membership.join; - final overlayMessage = ConstrainedBox( + final messageMargin = + pangeaMessageEvent.ownMessage ? Avatar.defaultSize + 16 : 8; + + double extraChatSpace = FluffyThemes.isColumnMode(context) + ? ((screenWidth - + (FluffyThemes.columnWidth * 3.5) - + FluffyThemes.navRailWidth) / + 2) + + messageMargin + : 0.0; + + if (extraChatSpace < 0) { + extraChatSpace = 0; + } + + final overlayMessage = Container( constraints: const BoxConstraints( maxWidth: FluffyThemes.columnWidth * 2.5, ), @@ -318,76 +336,72 @@ class MessageOverlayController extends State type: MaterialType.transparency, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: widget._pangeaMessageEvent.ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: widget._pangeaMessageEvent.ownMessage - ? MainAxisAlignment.end - : MainAxisAlignment.start, - children: [ - MessagePadding( - pangeaMessageEvent: pangeaMessageEvent, - child: MessageToolbar( - pangeaMessageEvent: widget._pangeaMessageEvent, - overLayController: this, - ), - ), - ], + MessageToolbar( + pangeaMessageEvent: widget._pangeaMessageEvent, + overLayController: this, ), - Message( - widget._event, - onSwipe: () => {}, - onInfoTab: (_) => {}, - onAvatarTab: (_) => {}, - scrollToEventId: (_) => {}, - onSelect: (_) => {}, + OverlayMessage( + pangeaMessageEvent, immersionMode: widget.chatController.choreographer.immersionMode, controller: widget.chatController, - timeline: widget.chatController.timeline!, overlayController: this, - animateIn: false, nextEvent: widget._nextEvent, - previousEvent: widget._prevEvent, + prevEvent: widget._prevEvent, + timeline: widget.chatController.timeline!, + messageWidth: messageSize!.width, + ), + ToolbarButtons( + overlayController: this, + width: 250, ), - // MessageReactions(widget._event, widget.chatController.timeline!), - // const SizedBox(height: 6), - // MessagePadding( - // pangeaMessageEvent: pangeaMessageEvent, - // child: ToolbarButtons(overlayController: this, width: 250), - // ), ], ), ), ); + final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; + final columnOffset = FluffyThemes.isColumnMode(context) + ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth + : 0; + + final double leftPadding = widget._pangeaMessageEvent.ownMessage + ? extraChatSpace + : messageOffset!.dx - horizontalPadding - columnOffset; + + final double rightPadding = widget._pangeaMessageEvent.ownMessage + ? screenWidth - + messageOffset!.dx - + messageSize!.width - + horizontalPadding + : extraChatSpace; + final positionedOverlayMessage = _overlayPositionAnimation == null ? Positioned( - left: 0, - right: showDetails ? FluffyThemes.columnWidth : 0, - bottom: screenHeight - messageOffset!.dy - messageSize!.height, - child: Align( - alignment: Alignment.center, - child: overlayMessage, - ), + left: leftPadding, + right: rightPadding, + bottom: screenHeight - messageOffset!.dy - messageSize!.height - 50, + child: overlayMessage, ) : AnimatedBuilder( animation: _overlayPositionAnimation!, builder: (context, child) { return Positioned( - left: 0, - right: showDetails ? FluffyThemes.columnWidth : 0, + left: leftPadding, + right: rightPadding, bottom: _overlayPositionAnimation!.value, - child: Align( - alignment: Alignment.center, - child: overlayMessage, - ), + child: overlayMessage, ); }, ); return Padding( padding: EdgeInsets.only( - left: FluffyThemes.isColumnMode(context) ? 8.0 : 0.0, - right: FluffyThemes.isColumnMode(context) ? 8.0 : 0.0, + left: horizontalPadding, + right: horizontalPadding, ), child: Stack( children: [ diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index f0c0dde80..0e5b40b7e 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -8,7 +8,6 @@ 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_toolbar_buttons.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'; @@ -124,12 +123,6 @@ class MessageToolbarState extends State { child: Column( children: [ Container( - constraints: const BoxConstraints( - maxHeight: AppConfig.toolbarMaxHeight, - maxWidth: 350, - minWidth: 350, - ), - padding: const EdgeInsets.all(0), decoration: BoxDecoration( color: Theme.of(context).cardColor, border: Border.all( @@ -140,10 +133,9 @@ class MessageToolbarState extends State { Radius.circular(AppConfig.borderRadius), ), ), - child: Column( - mainAxisSize: MainAxisSize.min, + child: Row( children: [ - Flexible( + Expanded( child: SingleChildScrollView( child: AnimatedSize( duration: FluffyThemes.animationDuration, @@ -154,12 +146,6 @@ class MessageToolbarState extends State { ], ), ), - const SizedBox(height: 6), - ToolbarButtons( - overlayController: widget.overLayController, - width: 250, - ), - const SizedBox(height: 6), ], ), ); diff --git a/lib/pangea/widgets/chat/message_toolbar_buttons.dart b/lib/pangea/widgets/chat/message_toolbar_buttons.dart index c0d021caf..bd5b0802b 100644 --- a/lib/pangea/widgets/chat/message_toolbar_buttons.dart +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -37,6 +37,7 @@ class ToolbarButtons extends StatelessWidget { return SizedBox( width: width, + height: 50, child: Stack( alignment: Alignment.center, children: [ diff --git a/lib/pangea/widgets/chat/overlay_message.dart b/lib/pangea/widgets/chat/overlay_message.dart new file mode 100644 index 000000000..1b444af6c --- /dev/null +++ b/lib/pangea/widgets/chat/overlay_message.dart @@ -0,0 +1,117 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/events/message_content.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/utils/date_time_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class OverlayMessage extends StatelessWidget { + final PangeaMessageEvent pangeaMessageEvent; + final MessageOverlayController overlayController; + final ChatController controller; + final Event? nextEvent; + final Event? prevEvent; + final Timeline timeline; + final bool immersionMode; + final double messageWidth; + + const OverlayMessage( + this.pangeaMessageEvent, { + this.immersionMode = false, + required this.overlayController, + required this.controller, + required this.timeline, + required this.messageWidth, + this.nextEvent, + this.prevEvent, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bool ownMessage = + pangeaMessageEvent.event.senderId == Matrix.of(context).client.userID; + + final displayTime = + pangeaMessageEvent.event.type == EventTypes.RoomCreate || + nextEvent == null || + !pangeaMessageEvent.event.originServerTs + .sameEnvironment(nextEvent!.originServerTs); + + final nextEventSameSender = nextEvent != null && + { + EventTypes.Message, + EventTypes.Sticker, + EventTypes.Encrypted, + }.contains(nextEvent!.type) && + nextEvent!.senderId == pangeaMessageEvent.event.senderId && + !displayTime; + + final previousEventSameSender = prevEvent != null && + { + EventTypes.Message, + EventTypes.Sticker, + EventTypes.Encrypted, + }.contains(prevEvent!.type) && + prevEvent!.senderId == pangeaMessageEvent.event.senderId && + prevEvent!.originServerTs + .sameEnvironment(pangeaMessageEvent.event.originServerTs); + + const hardCorner = Radius.circular(4); + const roundedCorner = Radius.circular(AppConfig.borderRadius); + final borderRadius = BorderRadius.only( + topLeft: !ownMessage && nextEventSameSender ? hardCorner : roundedCorner, + topRight: ownMessage && nextEventSameSender ? hardCorner : roundedCorner, + bottomLeft: + !ownMessage && previousEventSameSender ? hardCorner : roundedCorner, + bottomRight: + ownMessage && previousEventSameSender ? hardCorner : roundedCorner, + ); + + final displayEvent = pangeaMessageEvent.event.getDisplayEvent(timeline); + var color = theme.colorScheme.surfaceContainerHighest; + if (ownMessage) { + color = displayEvent.status.isError + ? Colors.redAccent + : theme.colorScheme.primary; + } + + return Material( + color: color, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + // #Pangea + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + width: messageWidth, + child: MessageContent( + pangeaMessageEvent.event, + textColor: ownMessage + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + pangeaMessageEvent: pangeaMessageEvent, + immersionMode: immersionMode, + overlayController: overlayController, + controller: controller, + nextEvent: nextEvent, + prevEvent: prevEvent, + borderRadius: borderRadius, + ), + ), + ); + } +} From ef8c677681e7d07db56610c7fd951fda12489718 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 8 Oct 2024 09:17:34 -0400 Subject: [PATCH 24/25] removed pangea comment in overlay message file, made toolbar button height into variable for consistency in height adjustments --- .../chat/message_selection_overlay.dart | 31 +++++++++++++------ lib/pangea/widgets/chat/overlay_message.dart | 1 - 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index dbde10f52..496c7ce66 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -215,6 +215,8 @@ class MessageOverlayController extends State PangeaTokenText? get selectedSpan => _selectedSpan; + final int toolbarButtonsHeight = 50; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -225,11 +227,13 @@ class MessageOverlayController extends State // position the overlay directly over the underlying message final headerBottomOffset = screenHeight - headerHeight; final footerBottomOffset = footerHeight; - final currentBottomOffset = - screenHeight - messageOffset!.dy - messageSize!.height - 50; + final currentBottomOffset = screenHeight - + messageOffset!.dy - + messageSize!.height - + toolbarButtonsHeight; - final bool hasHeaderOverflow = - (messageOffset!.dy - 50) < (AppConfig.toolbarMaxHeight + headerHeight); + final bool hasHeaderOverflow = (messageOffset!.dy - toolbarButtonsHeight) < + (AppConfig.toolbarMaxHeight + headerHeight); final bool hasFooterOverflow = footerHeight > currentBottomOffset; if (!hasHeaderOverflow && !hasFooterOverflow) return; @@ -242,7 +246,8 @@ class MessageOverlayController extends State // if the overlay would have a footer overflow for this message, // check if shifting the overlay up could cause a header overflow final bottomOffsetDifference = footerHeight - currentBottomOffset; - final newTopOffset = messageOffset!.dy - bottomOffsetDifference - 50; + final newTopOffset = + messageOffset!.dy - bottomOffsetDifference - toolbarButtonsHeight; final bool upshiftCausesHeaderOverflow = hasFooterOverflow && newTopOffset < (headerHeight + AppConfig.toolbarMaxHeight); @@ -313,19 +318,22 @@ class MessageOverlayController extends State FluffyThemes.isThreeColumnMode(context) && widget.chatController.room.membership == Membership.join; - final messageMargin = + // the default spacing between the side of the screen and the message bubble + final double messageMargin = pangeaMessageEvent.ownMessage ? Avatar.defaultSize + 16 : 8; + // the actual spacing between the side of the screen and + // the message bubble, accounts for wide screen double extraChatSpace = FluffyThemes.isColumnMode(context) ? ((screenWidth - (FluffyThemes.columnWidth * 3.5) - FluffyThemes.navRailWidth) / 2) + messageMargin - : 0.0; + : messageMargin; - if (extraChatSpace < 0) { - extraChatSpace = 0; + if (extraChatSpace < messageMargin) { + extraChatSpace = messageMargin; } final overlayMessage = Container( @@ -383,7 +391,10 @@ class MessageOverlayController extends State ? Positioned( left: leftPadding, right: rightPadding, - bottom: screenHeight - messageOffset!.dy - messageSize!.height - 50, + bottom: screenHeight - + messageOffset!.dy - + messageSize!.height - + toolbarButtonsHeight, child: overlayMessage, ) : AnimatedBuilder( diff --git a/lib/pangea/widgets/chat/overlay_message.dart b/lib/pangea/widgets/chat/overlay_message.dart index 1b444af6c..07f83c8b5 100644 --- a/lib/pangea/widgets/chat/overlay_message.dart +++ b/lib/pangea/widgets/chat/overlay_message.dart @@ -86,7 +86,6 @@ class OverlayMessage extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: borderRadius, ), - // #Pangea child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular( From ca3cd036645aa8c64cd454e9d3eac9ec7452e44d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 8 Oct 2024 14:13:01 -0400 Subject: [PATCH 25/25] blur background during IT --- lib/pages/chat/chat_view.dart | 4 ++ .../widgets/chat/chat_view_background.dart | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 lib/pangea/widgets/chat/chat_view_background.dart diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 75cb583d3..52d5ed351 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart'; +import 'package:fluffychat/pangea/widgets/chat/chat_view_background.dart'; import 'package:fluffychat/pangea/widgets/chat/input_bar_wrapper.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; @@ -419,6 +420,9 @@ class ChatView extends StatelessWidget { ], ), // #Pangea + ChatViewBackground( + choreographer: controller.choreographer, + ), Positioned( left: 0, right: 0, diff --git a/lib/pangea/widgets/chat/chat_view_background.dart b/lib/pangea/widgets/chat/chat_view_background.dart new file mode 100644 index 000000000..001a9a3ae --- /dev/null +++ b/lib/pangea/widgets/chat/chat_view_background.dart @@ -0,0 +1,60 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; +import 'package:flutter/material.dart'; + +class ChatViewBackground extends StatefulWidget { + final Choreographer choreographer; + const ChatViewBackground({ + super.key, + required this.choreographer, + }); + + @override + ChatViewBackgroundState createState() => ChatViewBackgroundState(); +} + +class ChatViewBackgroundState extends State { + StreamSubscription? _choreoSub; + + @override + void initState() { + // Rebuild the widget each time there's an update from choreo + _choreoSub = widget.choreographer.stateListener.stream.listen((_) { + setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + _choreoSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.choreographer.itController.willOpen + ? Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Material( + borderOnForeground: false, + color: const Color.fromRGBO(0, 0, 0, 1).withAlpha(150), + clipBehavior: Clip.antiAlias, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5), + child: Container( + height: double.infinity, + width: double.infinity, + color: Colors.transparent, + ), + ), + ), + ) + : const SizedBox.shrink(); + } +}