Merge branch 'main' into 688-difficult-to-click-on-a-an-audio-message-for-feedback

This commit is contained in:
ggurdin 2024-10-08 14:46:43 -04:00 committed by GitHub
commit 25b62b50b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 3587 additions and 1971 deletions

View file

@ -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

View file

@ -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."
}

View file

@ -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"
}

16
env.ocal_choreo Normal file
View file

@ -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'

View file

@ -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<ChatPageWithRoom>
//#Pangea
choreographer.stateListener.close();
choreographer.dispose();
MatrixState.pAnyState.closeOverlay();
//Pangea#
super.dispose();
}
@ -652,16 +654,26 @@ class ChatController extends State<ChatPageWithRoom>
// 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<ChatPageWithRoom>
/// 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<ChatPageWithRoom>
});
// #Pangea
final textSelection = MessageTextSelection();
void showToolbar(
PangeaMessageEvent pangeaMessageEvent, {
MessageMode? mode,
@ -1643,10 +1652,9 @@ class ChatController extends State<ChatPageWithRoom>
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<ChatPageWithRoom>
onSelectMessage(pangeaMessageEvent.event);
HapticFeedback.mediumImpact();
}
// Pangea#
// final List<int> 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<bool> displayChatDetailsColumn;

View file

@ -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,

View file

@ -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) {

View file

@ -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#

View file

@ -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:

View file

@ -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<NewGroup> {
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<NewGroup> {
});
return;
}
} else if (botOptions.mode == "text_adventure") {
} else if (botOptions.mode == BotMode.textAdventure) {
if (botOptions.textAdventureGameMasterInstructions == null ||
botOptions.textAdventureGameMasterInstructions!.isEmpty) {
setState(() {

View file

@ -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<void> 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<void> 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)

View file

@ -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.

View file

@ -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,
);
}

View file

@ -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<void> initializeIT(ITStartData itStartData) async {
_willOpen = true;
Future.delayed(const Duration(microseconds: 100), () {

View file

@ -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<Choice>? 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<ChoicesArray> {
? 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<int, Choice> 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<Color>(
entry.value.color != null
? entry.value.color!.withOpacity(0.2)
: theme.colorScheme.primary.withOpacity(0.1),
),
backgroundColor: entry.value.color != null
? WidgetStateProperty.all<Color>(
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),

View file

@ -49,12 +49,6 @@ class ITBarState extends State<ITBar> {
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<ITBar> {
// 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,
);

View file

@ -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,

View file

@ -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],
);

View file

@ -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;
}

View file

@ -0,0 +1,6 @@
class BotMode {
static const discussion = "discussion";
static const custom = "custom";
static const storyGame = "story_game";
static const textAdventure = "text_adventure";
}

View file

@ -1,8 +1,8 @@
import 'dart:async';
class BaseController {
final StreamController stateListener = StreamController();
late Stream stateStream;
class BaseController<T> {
final StreamController<T> stateListener = StreamController<T>();
late Stream<T> stateStream;
BaseController() {
stateStream = stateListener.stream.asBroadcastStream();
@ -12,7 +12,7 @@ class BaseController {
stateListener.close();
}
setState({dynamic data}) {
setState(T data) {
stateListener.add(data);
}
}

View file

@ -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

View file

@ -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

View file

@ -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<CacheItem> _cache = [];
final List<RepresentationCacheItem> _representationCache = [];
final Map<int, Future<List<PangeaToken>>> _tokensCache = {};
final Map<int, Future<PangeaRepresentation>> _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<PangeaMessageTokens?> _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<Event?> _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<List<PangeaToken>> _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<Event?> 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<List<PangeaToken>> 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<Event?> _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<PangeaRepresentation> 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<PangeaRepresentation?> getPangeaRepresentation({
required String text,
required String? source,
required String target,
required Room room,
Future<PangeaRepresentation> _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<PangeaRepresentation?> _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<Event?> 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<RepTokensAndRecord> 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<String, dynamic> toJson() => {
"rep": representation.toJson(),
"choreoRecord": choreoRecord?.toJson(),
"tokens": tokens?.toJson(),
};
}
class CacheItem {
String parentId;
String langCode;
String type;
Future<Event?> data;
CacheItem(this.parentId, this.type, this.langCode, this.data);
}
class RepresentationCacheItem {
String parentId;
String langCode;
Future<PangeaRepresentation?> data;
RepresentationCacheItem(this.parentId, this.langCode, this.data);
}

View file

@ -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<AnalyticsStream> {
late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
StreamSubscription? _messageSendSubscription;
StreamSubscription<AnalyticsStream>? _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<String, dynamic>));
_analyticsStream ??=
stateStream.listen((data) => _onNewAnalyticsData(data));
_refreshAnalyticsIfOutdated();
}
@ -73,8 +66,8 @@ class MyAnalyticsController extends BaseController {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_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<String, dynamic> 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<OneConstructUse> 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<OneConstructUse> 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<OneConstructUse> getDraftUses(String roomID) {
List<OneConstructUse> _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<void> addLocalMessage(
String eventID,
Future<void> _addLocalMessage(
String cacheKey,
List<OneConstructUse> 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<String, List<OneConstructUse>> 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<void> setMessagesSinceUpdate(
Future<void> _setMessagesSinceUpdate(
Map<String, List<OneConstructUse>> 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<void> updateAnalytics() async {
Future<void> 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<OneConstructUse> constructs;
AnalyticsStream({
required this.eventId,
required this.roomId,
required this.constructs,
});
}

View file

@ -105,7 +105,7 @@ class PangeaController {
speechToText = SpeechToTextController(this);
languageDetection = LanguageDetectionController(this);
activityRecordController = PracticeActivityRecordController(this);
practiceGenerationController = PracticeGenerationController();
practiceGenerationController = PracticeGenerationController(this);
PAuthGaurd.pController = this;
}

View file

@ -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?> practiceActivityEvent;
@ -27,7 +36,10 @@ class PracticeGenerationController {
static final Map<int, _RequestCacheItem> _cache = {};
Timer? _cacheClearTimer;
PracticeGenerationController() {
late PangeaController _pangeaController;
PracticeGenerationController(PangeaController pangeaController) {
_pangeaController = pangeaController;
_initializeCacheClearing();
}
@ -64,8 +76,35 @@ class PracticeGenerationController {
);
}
Future<MessageActivityResponse> _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<String, dynamic> 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<PracticeActivityEvent?> 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<PracticeActivityEvent?> 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,
),
);
}

View file

@ -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 {

View file

@ -77,7 +77,7 @@ class WordController extends BaseController {
if (local == null) {
if (_wordData.length > 100) _wordData.clear();
_wordData.add(w);
setState();
setState(null);
}
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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]!;
}
}

View file

@ -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<String, dynamic>? 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<String, dynamic>,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: _latestEdit.content,
m: "error parsing choreoRecord",
);
return null;
}
}
List<RepresentationEvent>? _representations;
List<RepresentationEvent> 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<String, dynamic>,
),
tokens: _latestEdit.content[ModelKey.tokensSent] != null
? PangeaMessageTokens.fromJson(
_latestEdit.content[ModelKey.tokensSent]
as Map<String, dynamic>,
)
: null,
choreo: _latestEdit.content[ModelKey.choreoRecord] != null
? ChoreoRecord.fromJson(
_latestEdit.content[ModelKey.choreoRecord]
as Map<String, dynamic>,
)
: null,
tokens: _tokensSafe(
_latestEdit.content[ModelKey.tokensSent] as Map<String, dynamic>?,
),
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<String, dynamic>,
),
tokens: _latestEdit.content[ModelKey.tokensWritten] != null
? PangeaMessageTokens.fromJson(
_latestEdit.content[ModelKey.tokensWritten]
as Map<String, dynamic>,
)
: null,
tokens: _tokensSafe(
_latestEdit.content[ModelKey.tokensWritten]
as Map<String, dynamic>?,
),
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<PangeaMatch>? 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<PracticeActivityEvent> activities = [];

View file

@ -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<PangeaMessageTokens>();
final PangeaMessageTokens storedTokens =
tokenEvents.first.getPangeaContent<PangeaMessageTokens>();
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<List<PangeaToken>?> tokensGlobal(BuildContext context) async {
Future<List<PangeaToken>> 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<PangeaToken> 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 {

View file

@ -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<PracticeActivityModel>();
return _content!;
try {
_content ??= event.getPangeaContent<PracticeActivityModel>();
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<PracticeActivityRecordEvent> 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<PracticeActivityRecordEvent> 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<PracticeActivityRecordEvent> 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,
);
}

View file

@ -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<OneConstructUse> _uses;
List<ConstructUses>? _constructList;
List<ConstructUseTypeUses>? _typedConstructs;
/// A map of lemmas to ConstructUses, each of which contains a lemma
/// key = lemmma + constructType.string, value = ConstructUses
Map<String, ConstructUses>? _constructMap;
ConstructListModel({
required this.type,
uses,
}) : _uses = uses ?? [];
List<ConstructUses>? _constructs;
List<ConstructUseTypeUses>? _typedConstructs;
required List<OneConstructUse> uses,
}) : _uses = uses;
List<OneConstructUse> 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<String> get lemmas => constructs.map((e) => e.lemma).toSet().toList();
List<String> 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<ConstructUses> 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<String, List<OneConstructUse>> 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<ConstructUses> 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<ConstructUseTypeUses> get typedConstructs {
if (_typedConstructs != null) return _typedConstructs!;
final List<ConstructUseTypeUses> typedConstructs = [];
for (final construct in constructs) {
for (final construct in constructList) {
final typeToUses = <ConstructUseTypeEnum, List<OneConstructUse>>{};
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<DateTime?>(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

View file

@ -76,8 +76,12 @@ class OneConstructUse {
String? lemma;
String? form;
List<String> 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<String, dynamic> 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<String>.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<String, dynamic> toJson([bool condensed = false]) {
Map<String, dynamic> toJson() {
final Map<String, dynamic> 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;

View file

@ -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

View file

@ -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<PangeaToken> 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<int> matchIndicesByOffset(int offset) {

View file

@ -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<PangeaToken> tokens, {
bool debugWalkThrough = false,
int startTokenIndex = 0,
int endTokenIndex = -1,
}) {
debugger(when: kDebugMode && debugWalkThrough);
if (endTokenIndex == -1) {
endTokenIndex = tokens.length;
}
final List<PangeaToken> 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<ConstructWithXP> 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<String, dynamic> 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;
}

View file

@ -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<String, dynamic> json) {
return ConstructWithXP(
id: ConstructIdentifier.fromJson(
json['construct_id'] as Map<String, dynamic>,
),
xp: json['xp'] as int,
lastUsed: json['last_used'] != null
? DateTime.parse(json['last_used'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
final json = {
'construct_id': id.toJson(),
'xp': xp,
'last_used': lastUsed?.toIso8601String(),
};
return json;
}
}
class TokenWithXP {
final PangeaToken token;
final List<ConstructWithXP> constructs;
DateTime? get lastUsed {
return constructs.fold<DateTime?>(
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<int>(
0,
(previousValue, element) => previousValue + element.xp,
);
}
TokenWithXP({
required this.token,
required this.constructs,
});
factory TokenWithXP.fromJson(Map<String, dynamic> json) {
return TokenWithXP(
token: PangeaToken.fromJson(json['token'] as Map<String, dynamic>),
constructs: (json['constructs'] as List)
.map((e) => ConstructWithXP.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> 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<ConstructIdentifier> tgtConstructs;
final ActivityTypeEnum activityType;
ExistingActivityMetaData({
required this.activityEventId,
required this.tgtConstructs,
required this.activityType,
});
factory ExistingActivityMetaData.fromJson(Map<String, dynamic> 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<String, dynamic>))
.toList(),
activityType: ActivityTypeEnum.values.firstWhere(
(element) =>
element.string == json['activity_type'] as String ||
element.string.split('.').last == json['activity_type'] as String,
),
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return ActivityQualityFeedback(
feedbackText: json['feedback_text'] as String,
badActivity: PracticeActivityModel.fromJson(
json['bad_activity'] as Map<String, dynamic>,
),
);
}
Map<String, dynamic> 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<TokenWithXP> tokensWithXP;
/// make the server aware of existing activities for potential reuse
final List<ExistingActivityMetaData> 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<String, dynamic> 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<String, dynamic>))
.toList(),
messageId: json['message_id'] as String,
existingActivities: (json['existing_activities'] as List)
.map(
(e) => ExistingActivityMetaData.fromJson(e as Map<String, dynamic>),
)
.toList(),
activityQualityFeedback: json['activity_quality_feedback'] != null
? ActivityQualityFeedback.fromJson(
json['activity_quality_feedback'] as Map<String, dynamic>,
)
: null,
);
}
Map<String, dynamic> 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<String, dynamic> json) {
return MessageActivityResponse(
activity: json['activity'] != null
? PracticeActivityModel.fromJson(
json['activity'] as Map<String, dynamic>,
)
: null,
finished: json['finished'] as bool,
existingActivityEventId: json['existing_activity_event_id'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'activity': activity?.toJson(),
'finished': finished,
'existing_activity_event_id': existingActivityEventId,
};
}
}

View file

@ -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<String, dynamic> 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(),
};
}
}

View file

@ -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<String, dynamic> 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<String, dynamic>))
.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<String, dynamic> 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;
}
}

View file

@ -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<OneConstructUse> uses(
PracticeActivityEvent practiceActivity, {
Event? event,
ConstructUseMetaData? metadata,
}) {
try {
if (event == null && metadata == null) {
debugger(when: kDebugMode);
return [];
}
final List<OneConstructUse> uses = [];
final List<ConstructIdentifier> 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<OneConstructUse> 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<OneConstructUse> 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<String, dynamic> json) {
return ActivityRecordResponse(
text: json['text'] as String?,

View file

@ -14,8 +14,17 @@ class PangeaMessageTokens {
});
factory PangeaMessageTokens.fromJson(Map<String, dynamic> 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<PangeaToken>(),

View file

@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View file

@ -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 =

View file

@ -198,7 +198,7 @@ class ConstructListViewState extends State<ConstructListView> {
setState(() => fetchingUses = true);
try {
final List<OneConstructUse> uses = constructs?.constructs
final List<OneConstructUse> uses = constructs?.constructList
.firstWhereOrNull(
(element) => element.lemma == currentLemma,
)
@ -276,7 +276,7 @@ class ConstructListViewState extends State<ConstructListView> {
);
}
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<ConstructListView> {
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 ||

View file

@ -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<ContextTranslationResponseModel> translate({
required String accessToken,
required ContextualTranslationRequestModel request,

View file

@ -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<String, FullTextTranslationResponseModel> _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<FullTextTranslationResponseModel> 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 {

View file

@ -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 {

View file

@ -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();
},
),
],
),
),
),

View file

@ -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<String, bool> _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<InstructionsToggle> {
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(() {});

View file

@ -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,

View file

@ -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:

View file

@ -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<LevelBar> {
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<LevelBar> {
return AnimatedLevelBar(
height: widget.progressBarDetails.height,
beginWidth: prevWidth,
endWidth: width,
endWidth: widget.details.width,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),

View file

@ -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,
});
}

View file

@ -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<ChatViewBackground> {
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();
}
}

View file

@ -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<MessageAudioCard> {
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<MessageAudioCard> {
);
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<MessageAudioCard> {
@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(),
);

View file

@ -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<MessageSelectionOverlay>
class MessageOverlayController extends State<MessageSelectionOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
Animation<double>? _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<MessageSelectionOverlay>
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<void> 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<MessageSelectionOverlay>
// 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<MessageSelectionOverlay>
// 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<MessageSelectionOverlay>
),
);
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<MessageSelectionOverlay>
}
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<MessageSelectionOverlay>
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<MessageSelectionOverlay>
.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<MessageSelectionOverlay>
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<MessageSelectionOverlay>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OverlayFooter(controller: widget.controller),
OverlayFooter(controller: widget.chatController),
],
),
),
@ -252,10 +438,32 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
),
),
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,
);
}
}

View file

@ -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<MessageSpeechToTextCard> {
}
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<MessageSpeechToTextCard> {
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(() => {}),
),
],
),
);
}
}

View file

@ -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<String?> selectionStream =
StreamController<String?>.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;
}
}

View file

@ -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<MessageToolbar> {
Widget? toolbarContent;
MessageMode? currentMode;
bool updatingMode = false;
late StreamSubscription<String?> 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,
],
),
),
],
),
);
}

View file

@ -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<MessageMode> 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(),
),
],
),
);
}
}

View file

@ -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,
);
}
}

View file

@ -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<MessageTranslationCard> {
PangeaRepresentation? repEvent;
String? selectionTranslation;
String? oldSelectedText;
bool _fetchingRepresentation = false;
bool _fetchingTranslation = false;
Future<void> 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<void> fetchRepresentationText() async {
if (l1Code == null) return;
repEvent = widget.messageEvent
@ -49,48 +63,48 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
}
}
Future<void> translateSelection() async {
if (widget.selection.selectedText == null ||
l1Code == null ||
l2Code == null ||
widget.selection.messageText == null) {
selectionTranslation = null;
Future<void> 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<void> loadTranslation(Future<void> Function() future) async {
Future<void> 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<MessageTranslationCard> {
String? get l2Code =>
MatrixState.pangeaController.languageController.activeL2Code();
@override
void initState() {
super.initState();
loadTranslation(() async {
final List<Future> 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<MessageTranslationCard> {
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)
],
),
);

View file

@ -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<Color>(
(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<Color>(
(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<Color>(
(AppConfig.primaryColor).withOpacity(0.1),
),
),
child: Text(L10n.of(context)!.getAccess),
),
),
),
],
],
),
);
}
}

View file

@ -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,
),
),
);
}
}

View file

@ -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<OverlayMessageText> {
final PangeaController pangeaController = MatrixState.pangeaController;
List<PangeaToken>? 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<TokenPosition> 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,
});
}

View file

@ -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,
),
),
),
);

View file

@ -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),

View file

@ -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),

View file

@ -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) {

View file

@ -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),
),
],
);
},
);
},
),
),
);
}
}

View file

@ -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(

View file

@ -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<String, String> 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,

View file

@ -256,14 +256,16 @@ class ConversationBotSettingsState extends State<ConversationBotSettings> {
},
);
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);
}
}
},
),

View file

@ -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;
}),
},
),

View file

@ -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),
),
),
),
],
],
),
),
);
}

View file

@ -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<PangeaRichText> {
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(

View file

@ -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<Color>(
(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<Color>(
(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<Color>(
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<Color>(
// AppConfig.primaryColor.withOpacity(0.1),
// ),
// ),
// onPressed: () {
// MatrixState.pangeaController.subscriptionController
// .dismissPaywall();
// MatrixState.pAnyState.closeOverlay();
// },
// child: Center(
// child: Text(L10n.of(context)!.continuedWithoutSubscription),
// ),
// ),
// ),
],
),
),

View file

@ -120,7 +120,7 @@ class SpanCardState extends State<SpanCard> {
}
}
Future<void> onChoiceSelect(int index) async {
Future<void> onChoiceSelect(String value, int index) async {
selectedChoiceIndex = index;
if (selectedChoice != null) {
if (!selectedChoice!.selected) {
@ -143,9 +143,9 @@ class SpanCardState extends State<SpanCard> {
}
}
/// Returns the list of choices that are not selected
/// Returns the list of distractor choices that are not selected
List<SpanChoice>? 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

View file

@ -18,10 +18,10 @@ class WhyButton extends StatelessWidget {
return TextButton(
onPressed: loading ? null : onPress,
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
backgroundColor: WidgetStateProperty.all<Color>(
AppConfig.primaryColor.withOpacity(0.1),
),
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // Border radius
side: const BorderSide(

View file

@ -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<WordDataCard> {
@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),
),
);
}
}

View file

@ -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),
);

View file

@ -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<MultipleChoiceActivity> {
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<MultipleChoiceActivity> {
.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,
),
],
),

View file

@ -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<StarAnimationWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityAnimation;
late Animation<double> _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<double>(begin: 0.8, end: 1.0).animate(_controller);
// Define the size animation
_sizeAnimation = Tween<double>(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
),
),
],
),
);
}
}

View file

@ -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<PracticeActivityCard> {
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<PracticeActivityEvent> 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<void> 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<PracticeActivityEvent> 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<PracticeActivityEvent?> _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<void> _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<dynamic> 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<Color>(
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,
),
),
],
),
);
}
}

View file

@ -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<PracticeActivity> {
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),
],
);
}
}

View file

@ -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<TokenWithXP>? _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<List<TokenWithXP>> 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<List<TokenWithXP>> _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<void> updateTokensWithConstructs(
List<OneConstructUse> 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;
}
}
}
}
}

View file

@ -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),
),
),
),
);
}
}

View file

@ -92,7 +92,7 @@ Future<void> pLanguageDialog(
future: () async {
try {
pangeaController.myAnalytics
.updateAnalytics()
.sendLocalAnalyticsToAnalyticsRoom()
.then((_) {
pangeaController.userController.updateProfile(
(profile) {

View file

@ -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"