diff --git a/.github/workflows/main_deploy.yaml b/.github/workflows/main_deploy.yaml index 9439b75ad..a697f9207 100644 --- a/.github/workflows/main_deploy.yaml +++ b/.github/workflows/main_deploy.yaml @@ -3,22 +3,22 @@ name: Main Deploy Workflow on: push: branches: - - development + - main workflow_dispatch: 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 diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6312008d6..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!", @@ -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": "Highlight a word or phrase 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", @@ -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", @@ -4120,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": "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", @@ -4161,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.", @@ -4228,5 +4229,9 @@ "grammar": "Grammar", "contactHasBeenInvitedToTheChat": "Contact has been invited to the chat", "inviteChat": "📨 Invite chat", - "chatName": "Chat name" + "chatName": "Chat name", + "reportContentIssueTitle": "Report content issue", + "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/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index fb9c91df6..49421500b 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,9 +4489,7 @@ "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", "translationTooltip": "Traducir", "audioTooltip": "Reproducir audio", @@ -4505,7 +4502,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", @@ -4513,6 +4510,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": { @@ -4685,7 +4683,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.", @@ -4709,8 +4706,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", @@ -4736,5 +4731,5 @@ } }, "commandHint_googly": "Enviar unos ojos saltones", - "@commandHint_googly": {} + "reportContentIssue": "Problema de contenido" } \ No newline at end of file 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/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 12fc40af1..2dd0ae476 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -15,10 +15,12 @@ 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'; 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'; @@ -27,7 +29,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'; @@ -551,6 +552,7 @@ class ChatController extends State //#Pangea choreographer.stateListener.close(); choreographer.dispose(); + MatrixState.pAnyState.closeOverlay(); //Pangea# super.dispose(); } @@ -652,16 +654,26 @@ 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( - data: { - 'eventID': msgEventId, - 'eventType': EventTypes.Message, - 'roomID': room.id, - 'originalSent': originalSent, - 'tokensSent': tokensSent, - 'choreo': choreo, - }, + AnalyticsStream( + eventId: msgEventId, + roomId: room.id, + constructs: [ + ...(choreo!.grammarConstructUses(metadata: metadata)), + ...(originalSent!.vocabUses( + choreo: choreo, + tokens: tokensSent!.tokens, + metadata: metadata, + )), + ], + ), ); } @@ -1303,8 +1315,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 +1621,6 @@ class ChatController extends State }); // #Pangea - final textSelection = MessageTextSelection(); - void showToolbar( PangeaMessageEvent pangeaMessageEvent, { MessageMode? mode, @@ -1643,10 +1652,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 +1679,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/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/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 4850803d8..c9e9dc3f8 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( @@ -240,7 +241,9 @@ class Message extends StatelessWidget { // ), // ) // else if (nextEventSameSender || ownMessage) - if (nextEventSameSender || ownMessage || isOverlay) + if (nextEventSameSender || + ownMessage || + overlayController != null) // Pangea# SizedBox( width: Avatar.defaultSize, @@ -282,7 +285,8 @@ class Message extends StatelessWidget { children: [ // #Pangea // if (!nextEventSameSender) - if (!nextEventSameSender && !isOverlay) + if (!nextEventSameSender && + overlayController == null) // Pangea# Padding( padding: const EdgeInsets.only( @@ -349,14 +353,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 @@ -449,7 +453,8 @@ class Message extends StatelessWidget { pangeaMessageEvent: pangeaMessageEvent, immersionMode: immersionMode, - isOverlay: isOverlay, + overlayController: + overlayController, controller: controller, nextEvent: nextEvent, prevEvent: previousEvent, @@ -537,7 +542,7 @@ class Message extends StatelessWidget { event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); // #Pangea // if (showReceiptsRow || displayTime || selected || displayReadMarker) { - if (!isOverlay && + if (overlayController == null && (showReceiptsRow || displayTime || displayReadMarker || @@ -578,7 +583,7 @@ class Message extends StatelessWidget { duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, // #Pangea - child: isOverlay || + child: overlayController != null || (!showReceiptsRow && !(pangeaMessageEvent?.showMessageButtons ?? false)) // child: !showReceiptsRow @@ -671,7 +676,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..2cddb6f67 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_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.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'; 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/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/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..9053d3353 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,33 @@ 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) { + if (kDebugMode) { + PangeaToken.reconstructText( + igc.igcTextData!.tokens, + debugWalkThrough: true, + ); + } + ErrorHandler.logError( + m: "reconstructed text not working", + 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/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/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/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index adf30dcb1..ff13da78f 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,24 +66,24 @@ class ChoicesArrayState extends State { ? ItShimmer(originalSpan: widget.originalSpan) : Wrap( alignment: WrapAlignment.center, - children: widget.choices - ?.asMap() - .entries - .map( - (entry) => ChoiceItem( - theme: theme, - onLongPress: - widget.isActive ? widget.onLongPress : null, - onPressed: widget.isActive ? widget.onPressed : (_) {}, - entry: entry, - interactionDisabled: interactionDisabled, - enableInteraction: enableInteractions, - disableInteraction: disableInteraction, - isSelected: widget.selectedChoiceIndex == entry.key, - ), - ) - .toList() ?? - [], + 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(), ); } } @@ -112,8 +115,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 +139,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 +171,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..9e37112ea 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( @@ -230,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( @@ -248,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) @@ -257,6 +253,7 @@ class OriginalText extends StatelessWidget { }, }, icon: const Icon(Icons.edit_outlined), + iconSize: 20, ), ), ], @@ -393,8 +390,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/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/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/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/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..03cb2d60b 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -1,6 +1,6 @@ import 'dart:async'; +import 'dart:math'; -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 +42,31 @@ 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(); + + // 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/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..77e6caf27 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'; @@ -25,11 +19,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? _analyticsStream; Timer? _updateTimer; Client get _client => _pangeaController.matrixState.client; @@ -60,9 +54,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)); + _analyticsStream ??= + stateStream.listen((data) => _onNewAnalyticsData(data)); _refreshAnalyticsIfOutdated(); } @@ -73,8 +66,8 @@ class MyAnalyticsController extends BaseController { _updateTimer?.cancel(); lastUpdated = null; lastUpdatedCompleter = Completer(); - _messageSendSubscription?.cancel(); - _messageSendSubscription = null; + _analyticsStream?.cancel(); + _analyticsStream = null; _refreshAnalyticsIfOutdated(); clearMessagesSinceUpdate(); } @@ -103,77 +96,36 @@ 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(); - }); + void _onNewAnalyticsData(AnalyticsStream data) { + final List constructs = _getDraftUses(data.roomId); - // 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']; + constructs.addAll(data.constructs); - if (roomID == null || eventID == null) return; - - // convert that data into construct uses and add it to the cache - final metadata = ConstructUseMetaData( - roomId: roomID, - eventId: eventID, - timeStamp: DateTime.now(), - ); - - final List constructs = getDraftUses(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; - constructs.addAll([ - ...(grammarConstructs ?? []), - ...(vocabUses ?? []), - ]); - } - - if (eventType == PangeaEventTypes.activityRecord && - practiceActivity != null) { - final activityConstructs = recordModel?.uses( - practiceActivity, - metadata: metadata, - ); - constructs.addAll(activityConstructs ?? []); - } + final String eventID = data.eventId; + final String roomID = data.roomId; _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; - filtered.addAll(getDraftUses(roomID)); + final level = _pangeaController.analytics.level; - addLocalMessage(eventID, filtered).then( + + _addLocalMessage(eventID, filtered).then( (_) { - clearDraftUses(roomID); - afterAddLocalMessages(level); + _clearDraftUses(roomID); + _decideWhetherToUpdateAnalyticsRoom(level); }, ); }); @@ -216,40 +168,48 @@ 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}", + ); + } + } + 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( - String eventID, + Future _addLocalMessage( + 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); + 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', ); } } @@ -258,27 +218,46 @@ 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); } /// 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 - Future setMessagesSinceUpdate( + Future _setMessagesSinceUpdate( Map> cache, ) async { final formattedCache = {}; @@ -302,7 +281,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 +327,16 @@ class MyAnalyticsController extends BaseController { ); } } + +class AnalyticsStream { + final String eventId; + final String roomId; + + final List constructs; + + AnalyticsStream({ + required this.eventId, + required this.roomId, + required this.constructs, + }); +} 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..bbe961ff8 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,35 @@ 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 = MessageActivityResponse.fromJson(json); + + return response; + } else { + debugger(when: kDebugMode); + throw Exception('Failed to convert speech to text'); + } + } + + //TODO - allow return of activity content before sending the event + // this requires some downstream changes to the way the event is handled Future getPracticeActivity( - PracticeActivityRequest req, + MessageActivityRequest req, PangeaMessageEvent event, ) async { final int cacheKey = req.hashCode; @@ -75,8 +114,36 @@ 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 MessageActivityResponse res = await _fetch( + accessToken: _pangeaController.userController.accessToken, + requestModel: req, + ); + + // if the server points to an existing event, return that event + if (res.existingActivityEventId != null) { + final Event? existingEvent = + await event.room.getEventById(res.existingActivityEventId!); + + debugPrint( + 'Existing activity event found: ${existingEvent?.content}', + ); + 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(dummyModel(event), event); + _sendAndPackageEvent(res.activity!, event); _cache[cacheKey] = _RequestCacheItem(req: req, practiceActivityEvent: eventFuture); @@ -85,7 +152,7 @@ class PracticeGenerationController { } } - PracticeActivityModel dummyModel(PangeaMessageEvent event) => + PracticeActivityModel _dummyModel(PangeaMessageEvent event) => PracticeActivityModel( tgtConstructs: [ ConstructIdentifier(lemma: "be", type: ConstructTypeEnum.vocab), @@ -97,6 +164,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..73cf77dff 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 @@ -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/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/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/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/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index 11c96e10e..c8659f0fc 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,39 @@ extension MessageModeExtension on MessageMode { } } - Color? iconColor( - PangeaMessageEvent event, - MessageMode? currentMode, + bool isUnlocked( + int index, + int numActivitiesCompleted, + bool totallyDone, + ) => + numActivitiesCompleted >= index || totallyDone; + + Color iconButtonColor( BuildContext context, + int index, + MessageMode currentMode, + int numActivitiesCompleted, + bool totallyDone, ) { - 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, totallyDone)) { + 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..172f665f4 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, @@ -97,28 +98,43 @@ 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; } - 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 +145,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..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'; @@ -35,8 +36,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 @@ -56,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/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 661fa9d0b..d73b5060a 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(); + _uses.where((use) => use.constructType == type || type == null).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, + 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..10a47516a 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -76,8 +76,12 @@ class OneConstructUse { String? lemma; String? form; List categories; - ConstructTypeEnum? constructType; + 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; @@ -96,6 +100,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 +114,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 +124,7 @@ class OneConstructUse { ); } - Map toJson([bool condensed = false]) { + Map toJson() { final Map data = { 'useType': useType.string, 'chatId': metadata.roomId, @@ -125,10 +132,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/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/models/igc_text_data_model.dart b/lib/pangea/models/igc_text_data_model.dart index 014b39524..60497955b 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'; @@ -116,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) { @@ -126,14 +128,53 @@ 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; + + // for all tokens after the replacement, update their offsets + for (int i = endIndex; i < tokens.length; i++) { + tokens[i].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 && kDebugMode) { + PangeaToken.reconstructText(newTokens, debugWalkThrough: true); + ErrorHandler.logError( + m: "reconstructed text not working", + s: StackTrace.current, + data: { + "originalInput": originalInput, + "newFullText": newFullText, + "match": pangeaMatch.match.toJson(), + }, + ); + } + + 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" @@ -142,18 +183,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 +196,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..e6b577c20 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,47 @@ 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, { + bool debugWalkThrough = false, + int startTokenIndex = 0, + int endTokenIndex = -1, + }) { + debugger(when: kDebugMode && debugWalkThrough); + + if (endTokenIndex == -1) { + endTokenIndex = tokens.length; + } + + final List subset = + tokens.sublist(startTokenIndex, endTokenIndex); + + if (subset.isEmpty) { + debugger(when: kDebugMode); + return ''; + } + + if (subset.length == 1) { + return subset.first.text.content; + } + + String reconstruction = ""; + for (int i = 0; i < subset.length; i++) { + int whitespace = subset[i].text.offset - + (i > 0 ? (subset[i - 1].text.offset + subset[i - 1].text.length) : 0); + + if (whitespace < 0) { + 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 +112,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 +179,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..b8d44f41b --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -0,0 +1,267 @@ +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; + 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() { + final json = { + 'construct_id': id.toJson(), + 'xp': xp, + 'last_used': lastUsed?.toIso8601String(), + }; + return json; + } +} + +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 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'] ?? json['target_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, + '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; + + /// make the server aware of existing activities for potential reuse + final List existingActivities; + + final String messageId; + + MessageActivityRequest({ + required this.userL1, + required this.userL2, + required this.messageText, + required this.tokensWithXP, + required this.messageId, + required this.existingActivities, + required this.activityQualityFeedback, + }); + + 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, + existingActivities: (json['existing_activities'] as List) + .map( + (e) => ExistingActivityMetaData.fromJson(e as Map), + ) + .toList(), + activityQualityFeedback: json['activity_quality_feedback'] != null + ? ActivityQualityFeedback.fromJson( + json['activity_quality_feedback'] as Map, + ) + : null, + ); + } + + Map toJson() { + return { + 'user_l1': userL1, + 'user_l2': userL2, + 'message_text': messageText, + '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(), + }; + } + + // 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); + } +} + +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) { + return MessageActivityResponse( + activity: json['activity'] != null + ? PracticeActivityModel.fromJson( + json['activity'] as Map, + ) + : null, + finished: json['finished'] as bool, + existingActivityEventId: json['existing_activity_event_id'] as String?, + ); + } + + Map toJson() { + return { + 'activity': activity?.toJson(), + 'finished': finished, + 'existing_activity_event_id': existingActivityEventId, + }; + } +} 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..7c02a7aae 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 { @@ -238,9 +253,25 @@ 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) + tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs']) + as List) .map((e) => ConstructIdentifier.fromJson(e as Map)) .toList(), langCode: json['lang_code'] as String, @@ -248,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( @@ -269,12 +302,15 @@ 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(), @@ -282,20 +318,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 +380,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..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 @@ -5,12 +5,9 @@ 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,11 +54,9 @@ 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); + } void addResponse({ String? text, @@ -80,59 +75,26 @@ class PracticeActivityRecordModel { ), ); } catch (e) { - debugger(); + debugger(when: kDebugMode); } } /// 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. - List uses( - PracticeActivityEvent practiceActivity, { - Event? event, - 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, - ), - ), - ); - } - - return uses; - } catch (e, s) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: s, data: event?.toJson()); - rethrow; - } - } + /// The method iterates over the [responses] to get [OneConstructUse] objects for each + List usesForAllResponses( + PracticeActivityModel practiceActivity, + ConstructUseMetaData metadata, + ) => + responses + .toSet() + .expand( + (response) => response.toUses(practiceActivity, metadata), + ) + .toList(); @override bool operator ==(Object other) { @@ -168,6 +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/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/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/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/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/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/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/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/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(); + } +} diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 5c1f8e67b..b190da291 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -1,6 +1,8 @@ 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'; @@ -9,10 +11,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 @@ -45,7 +49,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 +60,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': @@ -70,29 +74,33 @@ 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(); } @override 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 - ? 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 758a7edec..496c7ce66 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -1,48 +1,66 @@ +import 'dart:developer'; + 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/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/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'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.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 + /// If we don't have any good activities for them, we'll decrease this number + static const int neededActivities = 3; + + int activitiesLeftToComplete = neededActivities; + + PangeaMessageEvent get pangeaMessageEvent => widget._pangeaMessageEvent; + @override void initState() { super.initState(); @@ -50,8 +68,155 @@ class MessageSelectionOverlayState extends State vsync: this, duration: FluffyThemes.animationDuration, ); + + activitiesLeftToComplete = activitiesLeftToComplete - + widget._pangeaMessageEvent.numberOfActivitiesCompleted; + + 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 + /// and check if the toolbar should be unlocked + void onActivityFinish() { + if (!mounted) return; + 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() { + clearSelection(); + activitiesLeftToComplete = 0; + setState(() {}); + } + + Future setInitialToolbarMode() async { + if (widget._pangeaMessageEvent.isAudioMessage) { + toolbarMode = MessageMode.speechToText; + return; + } + + if (activitiesLeftToComplete > 0) { + toolbarMode = MessageMode.practiceActivity; + return; + } + + if (MatrixState.pangeaController.userController.profile.userSettings + .autoPlayMessages) { + toolbarMode = MessageMode.textToSpeech; + return; + } + + toolbarMode = MessageMode.translation; + + 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 ([MessageMode.practiceActivity, MessageMode.textToSpeech] + .contains(toolbarMode)) { + return; + } + + // if there's no selected span, then select the token + if (_selectedSpan == null) { + _selectedSpan = token.text; + } else { + // if there is a selected span, then deselect the token if it's the same + if (isTokenSelected(token)) { + _selectedSpan = null; + } else { + // if there is a selected span but it is not the same, then select the token + _selectedSpan = token.text; + } + } + + setState(() {}); + } + + void clearSelection() { + _selectedSpan = null; + setState(() {}); + } + + void setSelectedSpan(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; + + final int toolbarButtonsHeight = 50; + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -62,11 +227,13 @@ class MessageSelectionOverlayState extends State // position the overlay directly over the underlying message final headerBottomOffset = screenHeight - headerHeight; final footerBottomOffset = footerHeight; - final currentBottomOffset = - screenHeight - messageOffset!.dy - messageSize!.height; + final currentBottomOffset = screenHeight - + messageOffset!.dy - + messageSize!.height - + toolbarButtonsHeight; - final bool hasHeaderOverflow = - messageOffset!.dy < (AppConfig.toolbarMaxHeight + headerHeight); + final bool hasHeaderOverflow = (messageOffset!.dy - toolbarButtonsHeight) < + (AppConfig.toolbarMaxHeight + headerHeight); final bool hasFooterOverflow = footerHeight > currentBottomOffset; if (!hasHeaderOverflow && !hasFooterOverflow) return; @@ -79,7 +246,8 @@ class MessageSelectionOverlayState 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 - toolbarButtonsHeight; final bool upshiftCausesHeaderOverflow = hasFooterOverflow && newTopOffset < (headerHeight + AppConfig.toolbarMaxHeight); @@ -108,8 +276,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 +291,7 @@ class MessageSelectionOverlayState extends State } RenderBox? get messageRenderBox => MatrixState.pAnyState.getRenderBox( - widget.event.eventId, + widget._event.eventId, ); Size? get messageSize => messageRenderBox?.size; @@ -139,6 +307,8 @@ class MessageSelectionOverlayState 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) @@ -146,9 +316,27 @@ 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( + // 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 + : messageMargin; + + if (extraChatSpace < messageMargin) { + extraChatSpace = messageMargin; + } + + final overlayMessage = Container( constraints: const BoxConstraints( maxWidth: FluffyThemes.columnWidth * 2.5, ), @@ -156,77 +344,75 @@ class MessageSelectionOverlayState 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: [ - Padding( - padding: EdgeInsets.only( - left: widget.pangeaMessageEvent.ownMessage - ? 0 - : Avatar.defaultSize + 16, - right: widget.pangeaMessageEvent.ownMessage ? 8 : 0, - ), - child: MessageToolbar( - pangeaMessageEvent: widget.pangeaMessageEvent, - controller: widget.controller, - textSelection: widget.textSelection, - initialMode: widget.initialMode, - ), - ), - ], + MessageToolbar( + pangeaMessageEvent: widget._pangeaMessageEvent, + overLayController: this, ), - Message( - widget.event, - onSwipe: () => {}, - onInfoTab: (_) => {}, - onAvatarTab: (_) => {}, - scrollToEventId: (_) => {}, - onSelect: (_) => {}, - immersionMode: widget.controller.choreographer.immersionMode, - controller: widget.controller, - timeline: widget.controller.timeline!, - isOverlay: true, - animateIn: false, - nextEvent: widget.nextEvent, - previousEvent: widget.prevEvent, + OverlayMessage( + pangeaMessageEvent, + immersionMode: widget.chatController.choreographer.immersionMode, + controller: widget.chatController, + overlayController: this, + nextEvent: widget._nextEvent, + prevEvent: widget._prevEvent, + timeline: widget.chatController.timeline!, + messageWidth: messageSize!.width, + ), + 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 - + toolbarButtonsHeight, + 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: [ @@ -240,7 +426,7 @@ class MessageSelectionOverlayState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - OverlayFooter(controller: widget.controller), + OverlayFooter(controller: widget.chatController), ], ), ), @@ -252,10 +438,32 @@ class MessageSelectionOverlayState extends State ), ), Material( - child: OverlayHeader(controller: widget.controller), + child: OverlayHeader(controller: widget.chatController), ), ], ), ); } } + +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_speech_to_text_card.dart b/lib/pangea/widgets/chat/message_speech_to_text_card.dart index 3c06a45f3..012647b5a 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,8 @@ 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/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'; @@ -66,13 +68,6 @@ class MessageSpeechToTextCardState extends State { } void closeHint() { - MatrixState.pangeaController.instructions.turnOffInstruction( - InlineInstructions.speechToText.toString(), - ); - MatrixState.pangeaController.instructions.updateEnableInstructions( - InlineInstructions.speechToText.toString(), - true, - ); setState(() {}); } @@ -164,54 +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, - onPressed: () => MatrixState.pangeaController.instructions - .showInstructionsPopup( - context, - InstructionsEnum.tooltipInstructions, - widget.messageEvent.eventId, - true, + 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, - 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, ), - ), - ], - ), - MatrixState.pangeaController.instructions.getInstructionInlineTooltip( - context, - InlineInstructions.speechToText, - closeHint, - ), - ], + ], + ), + InlineTooltip( + instructionsEnum: InstructionsEnum.speechToText, + onClose: () => setState(() => {}), + ), + ], + ), ); } } 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..0e5b40b7e 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -1,35 +1,32 @@ -import 'dart:async'; +import 'dart:developer'; 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_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/pangea/widgets/select_to_define.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'; + +const double minCardHeight = 70; 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,289 +34,119 @@ 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; - } + Widget get toolbarContent { + final bool subscribed = + MatrixState.pangeaController.subscriptionController.isSubscribed; - if (widget.initialMode != null) { - updateMode(widget.initialMode!); - } else { - MatrixState.pangeaController.userController.profile.userSettings - .autoPlayMessages - ? updateMode(MessageMode.textToSpeech) - : updateMode(MessageMode.translation); - } - }); + if (!subscribed) { + return MessageUnsubscribedCard( + controller: widget.overLayController, + ); + } - 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!); + switch (widget.overLayController.toolbarMode) { + case MessageMode.translation: + return MessageTranslationCard( + messageEvent: widget.pangeaMessageEvent, + selection: widget.overLayController.selectedSpan, + ); + case MessageMode.textToSpeech: + return MessageAudioCard( + messageEvent: widget.pangeaMessageEvent, + overlayController: widget.overLayController, + ); + 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(), - ); - 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.fromLTRB(20, 20, 20, 10), - 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: [ - if (toolbarContent != null) - Flexible( - child: SingleChildScrollView( - child: AnimatedSize( - duration: FluffyThemes.animationDuration, - child: toolbarContent, + child: Column( + children: [ + Container( + 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), + ), + ), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + child: toolbarContent, + ), ), ), - ), - buttonRow, - ], - ), - ), - ); - } -} - -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 SelectionArea( - onSelectionChanged: (SelectedContent? selection) { - controller.textSelection.onSelection(selection?.plainText); - }, - 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, + ], + ), + ), + ], ), ); } 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..bd5b0802b --- /dev/null +++ b/lib/pangea/widgets/chat/message_toolbar_buttons.dart @@ -0,0 +1,109 @@ +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 StatelessWidget { + final MessageOverlayController overlayController; + final double width; + + const ToolbarButtons({ + required this.overlayController, + required this.width, + super.key, + }); + + PangeaMessageEvent get pangeaMessageEvent => + overlayController.pangeaMessageEvent; + + List get modes => MessageMode.values + .where((mode) => mode.isValidMode(pangeaMessageEvent.event)) + .toList(); + + static const double iconWidth = 36.0; + + @override + Widget build(BuildContext context) { + final double barWidth = width - iconWidth; + + if (overlayController.pangeaMessageEvent.isAudioMessage) { + return const SizedBox(); + } + + return SizedBox( + width: width, + height: 50, + child: Stack( + alignment: Alignment.center, + children: [ + Stack( + children: [ + Container( + width: 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 == overlayController.toolbarMode + ? Colors.white + : null, + isSelected: mode == overlayController.toolbarMode, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + mode.iconButtonColor( + context, + index, + overlayController.toolbarMode, + pangeaMessageEvent.numberOfActivitiesCompleted, + overlayController.isPracticeComplete, + ), + ), + ), + onPressed: mode.isUnlocked( + index, + pangeaMessageEvent.numberOfActivitiesCompleted, + overlayController.isPracticeComplete, + ) + ? () => 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 bcf837c0e..5e66d9966 100644 --- a/lib/pangea/widgets/chat/message_translation_card.dart +++ b/lib/pangea/widgets/chat/message_translation_card.dart @@ -1,11 +1,12 @@ 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/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'; @@ -13,13 +14,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 +29,25 @@ 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() { + debugPrint('MessageTranslationCard initState'); + super.initState(); + loadTranslation(); + } + + @override + void didUpdateWidget(covariant MessageTranslationCard oldWidget) { + if (oldWidget.selection != widget.selection) { + debugPrint('selection changed'); + loadTranslation(); + } + super.didUpdateWidget(oldWidget); + } + + Future fetchRepresentationText() async { if (l1Code == null) return; repEvent = widget.messageEvent @@ -49,48 +63,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,68 +113,36 @@ 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(), - ); - 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; + return (isWrittenInL1 || isTextIdentical); } @override Widget build(BuildContext context) { - if (!_fetchingRepresentation && + debugPrint('MessageTranslationCard build'); + if (!_fetchingTranslation && repEvent == null && selectionTranslation == null) { return const CardErrorWidget(); } return Container( - child: _fetchingRepresentation + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, + child: _fetchingTranslation ? const ToolbarContentLoadingIndicator() : Column( children: [ - selectionTranslation != null + widget.selection != null ? Text( selectionTranslation!, style: BotStyle.text(context), @@ -169,12 +151,17 @@ 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 d1ff5c343..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.updateMode(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.dart b/lib/pangea/widgets/chat/overlay_message.dart new file mode 100644 index 000000000..07f83c8b5 --- /dev/null +++ b/lib/pangea/widgets/chat/overlay_message.dart @@ -0,0 +1,116 @@ +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, + ), + 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, + ), + ), + ); + } +} 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..4d91ed359 --- /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 = () { + debugPrint( + '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/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/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..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'; @@ -13,6 +12,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'; @@ -59,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() { @@ -142,12 +142,17 @@ 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, + width: levelBarWidth * _pangeaController.analytics.levelProgress, ), LevelBarDetails( fillColor: Theme.of(context).colorScheme.primary, currentPoints: serverXP, + width: + levelBarWidth * _pangeaController.analytics.serverLevelProgress, ), ], progressBarDetails: ProgressBarDetails( @@ -239,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/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/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/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/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/pangea_rich_text.dart b/lib/pangea/widgets/igc/pangea_rich_text.dart index cdb102414..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'; @@ -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/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/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index 5ddf1b0c5..816e8ed15 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) { @@ -143,9 +143,9 @@ class SpanCardState extends State { } } - /// 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 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( diff --git a/lib/pangea/widgets/igc/word_data_card.dart b/lib/pangea/widgets/igc/word_data_card.dart index 1d789424d..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'; @@ -76,6 +77,7 @@ class WordDataCardController extends State { @override void didUpdateWidget(covariant WordDataCard oldWidget) { + // debugger(when: kDebugMode); if (oldWidget.word != widget.word) { if (!widget.hasInfo) { getContextualDefinition(); @@ -173,56 +175,61 @@ class WordDataCardView extends StatelessWidget { final ScrollController scrollController = ScrollController(); - return Scrollbar( - thumbVisibility: true, - controller: scrollController, - child: SingleChildScrollView( + return Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(minHeight: minCardHeight), + alignment: Alignment.center, + 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), + ), + ], + ), ), ), ); @@ -396,20 +403,3 @@ class PartOfSpeechBlock extends StatelessWidget { ); } } - -class SelectToDefine extends StatelessWidget { - const SelectToDefine({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8), - child: Text( - L10n.of(context)!.selectToDefine, - style: BotStyle.text(context), - ), - ); - } -} 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..d3b57dc45 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -1,19 +1,24 @@ +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 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,60 +30,65 @@ class MultipleChoiceActivityState extends State { int? selectedChoiceIndex; PracticeActivityRecordModel? get currentRecordModel => - widget.controller.currentRecordModel; - - bool get isSubmitted => - widget.currentActivity?.userRecord?.record.latestResponse != null; + widget.practiceCardController.currentCompletionRecord; @override void initState() { super.initState(); - 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) { + 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?.userRecord?.record == null) { - widget.controller.setCurrentModel( - PracticeActivityRecordModel( - question: - widget.currentActivity?.practiceActivity.multipleChoice!.question, - ), - ); - selectedChoiceIndex = null; - } else { - widget.controller - .setCurrentModel(widget.currentActivity!.userRecord!.record); - selectedChoiceIndex = widget - .currentActivity?.practiceActivity.multipleChoice! - .choiceIndex(currentRecordModel!.latestResponse!.text!); + void updateChoice(String value, int index) { + if (currentRecordModel?.hasTextResponse(value) ?? false) { + return; } - setState(() {}); - } - void updateChoice(int index) { + final bool isCorrect = widget + .currentActivity!.practiceActivity.multipleChoice! + .isCorrect(value, index); + 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, + ); + + 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! + .isCorrect(value, index)) { + widget.practiceCardController.onActivityFinish(); + } + + setState( + () => selectedChoiceIndex = index, ); - setState(() => selectedChoiceIndex = index); } @override @@ -112,14 +122,15 @@ 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(), - isActive: !isSubmitted, + isActive: true, ), ], ), 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..1cef6c174 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/no_more_practice_card.dart @@ -0,0 +1,97 @@ +import 'package:fluffychat/pangea/utils/bot_style.dart'; +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: 60.0, + width: 60.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: [ + 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 + Container( + constraints: const BoxConstraints( + minHeight: 80, + ), + padding: const EdgeInsets.all(8), + child: Text( + userMessage, + 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 6e24c9e8b..517cbcebe 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 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'dart:async'; +import 'dart:developer'; + +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/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'; 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/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'; 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,159 +43,336 @@ class PracticeActivityCard extends StatefulWidget { class MessagePracticeActivityCardState extends State { PracticeActivityEvent? currentActivity; - PracticeActivityRecordModel? currentRecordModel; - bool sending = false; + 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 => widget.pangeaMessageEvent.practiceActivities; - int get practiceEventIndex => practiceActivities.indexWhere( - (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; + // 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: 1500); + bool savoringTheJoy = false; @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; + if (mounted) setState(() => fetchingActivity = value); + } + + void _setPracticeActivity(PracticeActivityEvent? activity) { + //set elsewhere but just in case + fetchingActivity = false; + + currentActivity = activity; + + if (activity == null) { + widget.overlayController.exitPracticeFlow(); + return; + } + + //make new completion record + currentCompletionRecord = PracticeActivityRecordModel( + question: activity.practiceActivity.question, + ); + + widget.overlayController.setSelectedSpan(activity.practiceActivity); + } + + /// Get an existing activity if there is one. + /// If not, get a new activity from the server. + Future initialize() async { + _setPracticeActivity( + _fetchExistingIncompleteActivity() ?? await _fetchNewActivity(), + ); + } + + // 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? _fetchExistingIncompleteActivity() { + if (practiceActivities.isEmpty) { + return null; + } + final List incompleteActivities = practiceActivities.where((element) => !element.isComplete).toList(); - currentActivity ??= incompleteActivities.isNotEmpty - ? incompleteActivities.first - : practiceActivities.first; - setState(() {}); + + // 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; } - void setCurrentModel(PracticeActivityRecordModel? recordModel) { - currentRecordModel = recordModel; - } + Future _fetchNewActivity([ + ActivityQualityFeedback? activityFeedback, + ]) async { + try { + debugPrint('Fetching new activity'); - /// 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(() {}); - } - } + _updateFetchingActivity(true); - /// 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) { + // 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(), + activityQualityFeedback: activityFeedback, + ), + widget.pangeaMessageEvent, + ); + + _updateFetchingActivity(false); + + return ourNewActivity; + } catch (e, s) { + debugger(when: kDebugMode); ErrorHandler.logError( - e: error, - s: StackTrace.current, + e: e, + s: s, + m: 'Failed to get new 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( + } + } + + ConstructUseMetaData get metadata => ConstructUseMetaData( + eventId: widget.pangeaMessageEvent.eventId, + roomId: widget.pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), + ); + + Future _savorTheJoy() async { + debugger(when: savoringTheJoy && kDebugMode); + + setState(() => savoringTheJoy = true); + + await Future.delayed(appropriateTimeForJoy); + + if (mounted) setState(() => savoringTheJoy = false); + } + + /// Called when the user finishes an activity. + /// Saves the completion record and sends it to the server. + /// Fetches a new activity if there are any left to complete. + /// Exits the practice flow if there are no more activities. + void onActivityFinish() async { + try { + if (currentCompletionRecord == null || currentActivity == null) { + debugger(when: kDebugMode); + return; + } + + // 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, + }, + ); + } + } + + /// 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; + } + + _fetchNewActivity( + ActivityQualityFeedback( + feedbackText: feedback, + badActivity: currentActivity!.practiceActivity, + ), + ).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(); + }); + + // clear the current activity and record + currentActivity = null; + currentCompletionRecord = null; + } + + 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 + 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), + ); + } + } + + String? get userMessage { + if (!fetchingActivity && currentActivity == null) { + return L10n.of(context)!.noActivitiesFound; + } + return null; } @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, - // ); + if (userMessage != null) { + return GamifiedTextWidget(userMessage: userMessage!); } - return Column( - children: [ - PracticeActivity( - practiceEvent: currentActivity!, - controller: this, - ), - navigationButtons, - ], + + return Container( + constraints: const BoxConstraints( + maxWidth: 350, + minWidth: 350, + minHeight: minCardHeight, + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Main content + const Positioned( + child: PointsGainedAnimation(), + ), + 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: ContentIssueButton( + isActive: currentActivity != null, + submitFeedback: submitFeedback, + ), + ), + ], + ), ); } } 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/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/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), + ), + ), + ), + ); + } +} 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) { diff --git a/pubspec.yaml b/pubspec.yaml index 8e5f9fcee..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+3536 +version: 1.21.4+3539 environment: sdk: ">=3.0.0 <4.0.0"