Merge remote-tracking branch 'origin/main'

This commit is contained in:
Brord van Wierst 2024-09-28 19:34:57 +02:00
commit 4953e63bf8
No known key found for this signature in database
GPG key ID: 0922A3CA225DF791
68 changed files with 2229 additions and 1264 deletions

View file

@ -10,15 +10,15 @@ env:
WEB_APP_ENV: ${{ vars.WEB_APP_ENV }}
jobs:
switch-branch:
runs-on: ubuntu-latest
# switch-branch:
# runs-on: ubuntu-latest
steps:
- name: Checkout main branch
uses: actions/checkout@v3
# steps:
# - name: Checkout main branch
# uses: actions/checkout@v3
- name: Checkout different branch
run: git checkout development
# - name: Checkout different branch
# run: git checkout development
build_web:
runs-on: ubuntu-latest

View file

@ -3968,7 +3968,7 @@
"seeOptions": "See options",
"continuedWithoutSubscription": "Continue without subscribing",
"trialPeriodExpired": "Your trial period has expired",
"selectToDefine": "Highlight a word or phrase to see its definition!",
"selectToDefine": "Click a word to see its definition!",
"translations": "translations",
"messageAudio": "message audio",
"definitions": "definitions",
@ -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",

View file

@ -4505,7 +4505,7 @@
"age": {}
}
},
"selectToDefine": "¡Resalta una palabra o frase para ver su definición!",
"selectToDefine": "Clic una palabra para definirla",
"kickBotWarning": "Patear Pangea Bot eliminará el bot de conversación de este chat.",
"activateTrial": "Activar prueba gratuita",
"refresh": "Actualizar",

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,6 +15,7 @@ import 'package:fluffychat/pages/chat/event_info_dialog.dart';
import 'package:fluffychat/pages/chat/recording_dialog.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
@ -27,7 +28,6 @@ import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/utils/overlay.dart';
import 'package:fluffychat/pangea/utils/report_message.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_text_controller.dart';
import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart';
import 'package:fluffychat/utils/error_reporter.dart';
@ -654,14 +654,14 @@ class ChatController extends State<ChatPageWithRoom>
// stream sends the data for newly sent messages.
if (msgEventId != null) {
pangeaController.myAnalytics.setState(
data: {
'eventID': msgEventId,
'eventType': EventTypes.Message,
'roomID': room.id,
'originalSent': originalSent,
'tokensSent': tokensSent,
'choreo': choreo,
},
AnalyticsStream(
eventId: msgEventId,
eventType: EventTypes.Message,
roomId: room.id,
originalSent: originalSent,
tokensSent: tokensSent,
choreo: choreo,
),
);
}
@ -1303,8 +1303,7 @@ class ChatController extends State<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 +1609,6 @@ class ChatController extends State<ChatPageWithRoom>
});
// #Pangea
final textSelection = MessageTextSelection();
void showToolbar(
PangeaMessageEvent pangeaMessageEvent, {
MessageMode? mode,
@ -1643,10 +1640,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 +1667,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

@ -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(
@ -239,7 +240,9 @@ class Message extends StatelessWidget {
// ),
// )
// else if (nextEventSameSender || ownMessage)
if (nextEventSameSender || ownMessage || isOverlay)
if (nextEventSameSender ||
ownMessage ||
overlayController != null)
// Pangea#
SizedBox(
width: Avatar.defaultSize,
@ -281,7 +284,8 @@ class Message extends StatelessWidget {
children: [
// #Pangea
// if (!nextEventSameSender)
if (!nextEventSameSender && !isOverlay)
if (!nextEventSameSender &&
overlayController == null)
// Pangea#
Padding(
padding: const EdgeInsets.only(
@ -348,14 +352,14 @@ class Message extends StatelessWidget {
),
// #Pangea
child: CompositedTransformTarget(
link: isOverlay
link: overlayController != null
? LayerLinkAndKey('overlay_msg')
.link
: MatrixState.pAnyState
.layerLinkAndKey(event.eventId)
.link,
child: Container(
key: isOverlay
key: overlayController != null
? LayerLinkAndKey('overlay_msg')
.key
: MatrixState.pAnyState
@ -448,7 +452,8 @@ class Message extends StatelessWidget {
pangeaMessageEvent:
pangeaMessageEvent,
immersionMode: immersionMode,
isOverlay: isOverlay,
overlayController:
overlayController,
controller: controller,
nextEvent: nextEvent,
prevEvent: previousEvent,
@ -536,7 +541,7 @@ class Message extends StatelessWidget {
event.hasAggregatedEvents(timeline, RelationshipTypes.reaction);
// #Pangea
// if (showReceiptsRow || displayTime || selected || displayReadMarker) {
if (!isOverlay &&
if (overlayController == null &&
(showReceiptsRow ||
displayTime ||
displayReadMarker ||
@ -577,7 +582,7 @@ class Message extends StatelessWidget {
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
// #Pangea
child: isOverlay ||
child: overlayController != null ||
(!showReceiptsRow &&
!(pangeaMessageEvent?.showMessageButtons ?? false))
// child: !showReceiptsRow
@ -670,7 +675,7 @@ class Message extends StatelessWidget {
top: nextEventSameSender ? 1.0 : 4.0,
bottom:
// #Pangea
isOverlay
overlayController != null
? 0
:
// Pangea#

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_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message_text.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
@ -35,7 +37,7 @@ class MessageContent extends StatelessWidget {
//here rather than passing the choreographer? pangea rich text, a widget
//further down in the chain is also using pangeaController so its not constant
final bool immersionMode;
final bool isOverlay;
final MessageOverlayController? overlayController;
final ChatController controller;
final Event? nextEvent;
final Event? prevEvent;
@ -49,7 +51,7 @@ class MessageContent extends StatelessWidget {
// #Pangea
this.pangeaMessageEvent,
required this.immersionMode,
this.isOverlay = false,
this.overlayController,
required this.controller,
this.nextEvent,
this.prevEvent,
@ -121,6 +123,7 @@ class MessageContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
// debugger(when: overlayController != null);
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
final buttonTextColor = textColor;
switch (event.type) {
@ -208,7 +211,7 @@ class MessageContent extends StatelessWidget {
textColor: textColor,
room: event.room,
// #Pangea
isOverlay: isOverlay,
isOverlay: overlayController != null,
controller: controller,
pangeaMessageEvent: pangeaMessageEvent,
nextEvent: nextEvent,
@ -303,26 +306,26 @@ class MessageContent extends StatelessWidget {
decoration: event.redacted ? TextDecoration.lineThrough : null,
height: 1.3,
);
// debugger(when: overlayController != null);
if (overlayController != null && pangeaMessageEvent != null) {
return OverlayMessageText(
pangeaMessageEvent: pangeaMessageEvent!,
overlayController: overlayController!,
);
}
if (immersionMode && pangeaMessageEvent != null) {
return Flexible(
child: PangeaRichText(
style: messageTextStyle,
pangeaMessageEvent: pangeaMessageEvent!,
immersionMode: immersionMode,
isOverlay: isOverlay,
isOverlay: overlayController != null,
controller: controller,
),
);
}
if (isOverlay) {
controller.textSelection.setMessageText(
event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
hideReply: true,
),
);
}
// Pangea#
return
@ -330,7 +333,7 @@ class MessageContent extends StatelessWidget {
ToolbarSelectionArea(
controller: controller,
pangeaMessageEvent: pangeaMessageEvent,
isOverlay: isOverlay,
isOverlay: overlayController != null,
nextEvent: nextEvent,
prevEvent: prevEvent,
child:

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,28 @@ class Choreographer {
)
: null;
// we've got a rather elaborate method of updating tokens after matches are accepted
// so we need to check if the reconstructed text matches the current text
// if not, let's get the tokens again and log an error
if (igc.igcTextData?.tokens != null &&
PangeaToken.reconstructText(igc.igcTextData!.tokens) != currentText) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "reconstructed text does not match current text",
s: StackTrace.current,
data: {
"igcTextData": igc.igcTextData?.toJson(),
"choreoRecord": choreoRecord.toJson(),
},
);
await igc.getIGCTextData(onlyTokensAndLanguageDetection: true);
}
// TODO - why does both it and igc need to be enabled for choreo to be applicable?
// final ChoreoRecord? applicableChoreo =
// isITandIGCEnabled && igc.igcTextData != null ? choreoRecord : null;
// if tokens or language detection are not available, we should get them
// if tokens OR language detection are not available, we should get them
// notes
// 1) we probably need to move this to after we clear the input field
// or the user could experience some lag here.

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,20 +66,22 @@ class ChoicesArrayState extends State<ChoicesArray> {
? ItShimmer(originalSpan: widget.originalSpan)
: Wrap(
alignment: WrapAlignment.center,
children: widget.choices
?.asMap()
.entries
.map(
(entry) => ChoiceItem(
children: widget.choices!
.mapIndexed(
(index, entry) => ChoiceItem(
theme: theme,
onLongPress:
widget.isActive ? widget.onLongPress : null,
onPressed: widget.isActive ? widget.onPressed : (_) {},
entry: entry,
onPressed: widget.isActive
? widget.onPressed
: (String value, int index) {
debugger(when: kDebugMode);
},
entry: MapEntry(index, entry),
interactionDisabled: interactionDisabled,
enableInteraction: enableInteractions,
disableInteraction: disableInteraction,
isSelected: widget.selectedChoiceIndex == entry.key,
isSelected: widget.selectedChoiceIndex == index,
),
)
.toList() ??
@ -112,8 +117,8 @@ class ChoiceItem extends StatelessWidget {
final MapEntry<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 +141,28 @@ class ChoiceItem extends StatelessWidget {
child: Container(
margin: const EdgeInsets.all(2),
padding: EdgeInsets.zero,
decoration: isSelected
? BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: Border.all(
color: entry.value.color ?? theme.colorScheme.primary,
style: BorderStyle.solid,
width: 2.0,
),
)
: null,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: Border.all(
color: isSelected
? entry.value.color ?? theme.colorScheme.primary
: Colors.transparent,
style: BorderStyle.solid,
width: 2.0,
),
),
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 7),
),
//if index is selected, then give the background a slight primary color
backgroundColor: WidgetStateProperty.all<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 +173,11 @@ class ChoiceItem extends StatelessWidget {
),
),
onLongPress: onLongPress != null && !interactionDisabled
? () => onLongPress!(entry.key)
? () => onLongPress!(entry.value.text, entry.key)
: null,
onPressed:
interactionDisabled ? null : () => onPressed(entry.key),
onPressed: interactionDisabled
? null
: () => onPressed(entry.value.text, entry.key),
child: Text(
entry.value.text,
style: BotStyle.text(context),

View file

@ -393,8 +393,8 @@ class ITChoices extends StatelessWidget {
return Choice(text: "error", color: Colors.red);
}
}).toList(),
onPressed: (int index) => selectContinuance(index, context),
onLongPress: (int index) => showCard(context, index),
onPressed: (value, index) => selectContinuance(index, context),
onLongPress: (value, index) => showCard(context, index),
uniqueKeyForLayerLink: (int index) => "itChoices$index",
selectedChoiceIndex: null,
);

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,5 @@
import 'dart:async';
import 'package:fluffychat/pangea/constants/analytics_constants.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
@ -42,7 +41,9 @@ class GetAnalyticsController {
int get serverXP => currentXP - localXP;
/// Get the current level based on the number of xp points
int get level => currentXP ~/ AnalyticsConstants.xpPerLevel;
/// The formula is calculated from XP and modeled on RPG games
// int get level => 1 + sqrt((1 + 8 * currentXP / 100) / 2).floor();
int get level => currentXP ~/ 10;
void initialize() {
_analyticsUpdateSubscription ??= _pangeaController

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

@ -25,11 +25,11 @@ enum AnalyticsUpdateType { server, local }
/// handles the processing of analytics for
/// 1) messages sent by the user and
/// 2) constructs used by the user, both in sending messages and doing practice activities
class MyAnalyticsController extends BaseController {
class MyAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
StreamSubscription? _messageSendSubscription;
StreamSubscription<AnalyticsStream>? _messageSendSubscription;
Timer? _updateTimer;
Client get _client => _pangeaController.matrixState.client;
@ -48,7 +48,7 @@ class MyAnalyticsController extends BaseController {
final int _maxMessagesCached = 10;
/// the number of minutes before an automatic update is triggered
final int _minutesBeforeUpdate = 5;
final int _minutesBeforeUpdate = 2;
/// the time since the last update that will trigger an automatic update
final Duration _timeSinceUpdate = const Duration(days: 1);
@ -60,9 +60,8 @@ class MyAnalyticsController extends BaseController {
void initialize() {
// Listen to a stream that provides the eventIDs
// of new messages sent by the logged in user
_messageSendSubscription ??= stateStream
.where((data) => data is Map)
.listen((data) => onMessageSent(data as Map<String, dynamic>));
_messageSendSubscription ??=
stateStream.listen((data) => _onNewAnalyticsData(data));
_refreshAnalyticsIfOutdated();
}
@ -103,77 +102,59 @@ class MyAnalyticsController extends BaseController {
final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate);
if (lastUpdated?.isBefore(yesterday) ?? true) {
debugPrint("analytics out-of-date, updating");
await updateAnalytics();
await sendLocalAnalyticsToAnalyticsRoom();
}
}
/// Given the data from a newly sent message, format and cache
/// the message's construct data locally and reset the update timer
void onMessageSent(Map<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();
});
// extract the relevant data about this message
final String? eventID = data['eventID'];
final String? roomID = data['roomID'];
final String? eventType = data['eventType'];
final PangeaRepresentation? originalSent = data['originalSent'];
final PangeaMessageTokens? tokensSent = data['tokensSent'];
final ChoreoRecord? choreo = data['choreo'];
final PracticeActivityEvent? practiceActivity = data['practiceActivity'];
final PracticeActivityRecordModel? recordModel = data['recordModel'];
if (roomID == null || eventID == null) return;
void _onNewAnalyticsData(AnalyticsStream data) {
// convert that data into construct uses and add it to the cache
final metadata = ConstructUseMetaData(
roomId: roomID,
eventId: eventID,
roomId: data.roomId,
eventId: data.eventId,
timeStamp: DateTime.now(),
);
final List<OneConstructUse> constructs = getDraftUses(roomID);
final List<OneConstructUse> constructs = _getDraftUses(data.roomId);
if (eventType == EventTypes.Message) {
final grammarConstructs =
choreo?.grammarConstructUses(metadata: metadata);
final vocabUses = tokensSent != null
? originalSent?.vocabUses(
choreo: choreo,
tokens: tokensSent.tokens,
metadata: metadata,
)
: null;
if (data.eventType == EventTypes.Message) {
constructs.addAll([
...(grammarConstructs ?? []),
...(vocabUses ?? []),
...(data.choreo!.grammarConstructUses(metadata: metadata)),
...(data.originalSent!.vocabUses(
choreo: data.choreo,
tokens: data.tokensSent!.tokens,
metadata: metadata,
)),
]);
}
if (eventType == PangeaEventTypes.activityRecord &&
practiceActivity != null) {
final activityConstructs = recordModel?.uses(
practiceActivity,
} else if (data.eventType == PangeaEventTypes.activityRecord &&
data.practiceActivity != null) {
final activityConstructs = data.recordModel!.uses(
data.practiceActivity!,
metadata: metadata,
);
constructs.addAll(activityConstructs ?? []);
constructs.addAll(activityConstructs);
} else {
throw PangeaWarningError("Invalid event type for analytics stream");
}
final String eventID = data.eventId;
final String roomID = data.roomId;
_pangeaController.analytics
.filterConstructs(unfilteredConstructs: constructs)
.then((filtered) {
if (filtered.isEmpty) return;
filtered.addAll(getDraftUses(roomID));
// @ggurdin - are we sure this isn't happening twice? it's also above
filtered.addAll(_getDraftUses(data.roomId));
final level = _pangeaController.analytics.level;
addLocalMessage(eventID, filtered).then(
_addLocalMessage(eventID, filtered).then(
(_) {
clearDraftUses(roomID);
afterAddLocalMessages(level);
_clearDraftUses(roomID);
_decideWhetherToUpdateAnalyticsRoom(level);
},
);
});
@ -216,26 +197,28 @@ class MyAnalyticsController extends BaseController {
}
}
// @ggurdin - if the point of draft uses is that we don't want to send them twice,
// then, if this is triggered here, couldn't that make a problem?
final level = _pangeaController.analytics.level;
addLocalMessage('draft$roomID', uses).then(
(_) => afterAddLocalMessages(level),
_addLocalMessage('draft$roomID', uses).then(
(_) => _decideWhetherToUpdateAnalyticsRoom(level),
);
}
List<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(
Future<void> _addLocalMessage(
String eventID,
List<OneConstructUse> constructs,
) async {
@ -244,7 +227,7 @@ class MyAnalyticsController extends BaseController {
constructs.addAll(currentCache[eventID] ?? []);
currentCache[eventID] = constructs;
await setMessagesSinceUpdate(currentCache);
await _setMessagesSinceUpdate(currentCache);
} catch (e, s) {
ErrorHandler.logError(
e: PangeaWarningError("Failed to add message since update: $e"),
@ -258,17 +241,25 @@ class MyAnalyticsController extends BaseController {
/// If the addition brought the total number of messages in the cache
/// to the max, or if the addition triggered a level-up, update the analytics.
/// Otherwise, add a local update to the alert stream.
void afterAddLocalMessages(int prevLevel) {
void _decideWhetherToUpdateAnalyticsRoom(int prevLevel) {
// cancel the last timer that was set on message event and
// reset it to fire after _minutesBeforeUpdate minutes
_updateTimer?.cancel();
_updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () {
debugPrint("timer fired, updating analytics");
sendLocalAnalyticsToAnalyticsRoom();
});
if (_pangeaController.analytics.messagesSinceUpdate.length >
_maxMessagesCached) {
debugPrint("reached max messages, updating");
updateAnalytics();
sendLocalAnalyticsToAnalyticsRoom();
return;
}
final int newLevel = _pangeaController.analytics.level;
newLevel > prevLevel
? updateAnalytics()
? sendLocalAnalyticsToAnalyticsRoom()
: analyticsUpdateStream.add(AnalyticsUpdateType.local);
}
@ -278,7 +269,7 @@ class MyAnalyticsController extends BaseController {
}
/// Save the local cache of recently sent constructs to the local storage
Future<void> setMessagesSinceUpdate(
Future<void> _setMessagesSinceUpdate(
Map<String, List<OneConstructUse>> cache,
) async {
final formattedCache = {};
@ -302,7 +293,7 @@ class MyAnalyticsController extends BaseController {
/// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and
/// proceeds with the update process. If the update is successful, it clears any messages that were received
/// since the last update and notifies the [analyticsUpdateStream].
Future<void> updateAnalytics() async {
Future<void> sendLocalAnalyticsToAnalyticsRoom() async {
if (_pangeaController.matrixState.client.userID == null) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
@ -348,3 +339,46 @@ class MyAnalyticsController extends BaseController {
);
}
}
class AnalyticsStream {
final String eventId;
final String eventType;
final String roomId;
/// if the event is a message, the original message sent
final PangeaRepresentation? originalSent;
/// if the event is a message, the tokens sent
final PangeaMessageTokens? tokensSent;
/// if the event is a message, the choreo record
final ChoreoRecord? choreo;
/// if the event is a practice activity, the practice activity event
final PracticeActivityEvent? practiceActivity;
/// if the event is a practice activity, the record model
final PracticeActivityRecordModel? recordModel;
AnalyticsStream({
required this.eventId,
required this.eventType,
required this.roomId,
this.originalSent,
this.tokensSent,
this.choreo,
this.practiceActivity,
this.recordModel,
}) {
assert(
(originalSent != null && tokensSent != null && choreo != null) ||
(practiceActivity != null && recordModel != null),
"Either a message or a practice activity must be provided",
);
assert(
eventType == EventTypes.Message ||
eventType == PangeaEventTypes.activityRecord,
);
}
}

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,17 @@ class PracticeGenerationController {
} else {
//TODO - send request to server/bot, either via API or via event of type pangeaActivityReq
// for now, just make and send the event from the client
final MessageActivityResponse res = await _fetch(
accessToken: _pangeaController.userController.accessToken,
requestModel: req,
);
if (res.activity == null) {
return null;
}
final Future<PracticeActivityEvent?> eventFuture =
_sendAndPackageEvent(dummyModel(event), event);
_sendAndPackageEvent(res.activity!, event);
_cache[cacheKey] =
_RequestCacheItem(req: req, practiceActivityEvent: eventFuture);
@ -85,7 +133,7 @@ class PracticeGenerationController {
}
}
PracticeActivityModel dummyModel(PangeaMessageEvent event) =>
PracticeActivityModel _dummyModel(PangeaMessageEvent event) =>
PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(lemma: "be", type: ConstructTypeEnum.vocab),
@ -97,6 +145,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

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

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

@ -5,7 +5,6 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/enum/audio_encoding_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
@ -15,7 +14,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/utils/bot_name.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';
@ -351,6 +350,7 @@ class PangeaMessageEvent {
_representations?.add(
RepresentationEvent(
timeline: timeline,
parentMessageEvent: _event,
content: PangeaRepresentation(
langCode: response.langCode,
text: response.transcript.text,
@ -364,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) {
@ -415,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,
),
);
@ -444,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) {
@ -489,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
@ -547,17 +559,20 @@ class PangeaMessageEvent {
}
}
bool get showUseType =>
!ownMessage &&
_event.room.isSpaceAdmin &&
_event.senderId != BotName.byEnvironment &&
!room.isUserSpaceAdmin(_event.senderId) &&
_event.messageType != PangeaEventTypes.report &&
_event.messageType == MessageTypes.Text;
bool get showUseType => false;
// *note* turning this feature off but leave code here to bring back (if need)
// !ownMessage &&
// _event.room.isSpaceAdmin &&
// _event.senderId != BotName.byEnvironment &&
// !room.isUserSpaceAdmin(_event.senderId) &&
// _event.messageType != PangeaEventTypes.report &&
// _event.messageType == MessageTypes.Text;
// this is just showActivityIcon now but will include
// logic for showing
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.
///
@ -573,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
@ -588,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;
@ -637,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,
@ -102,23 +103,23 @@ class RepresentationEvent {
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 +130,7 @@ class RepresentationEvent {
),
);
if (tokensEvent == null) return null;
_tokens = TokensEvent(event: tokensEvent).tokens;
return _tokens?.tokens;
return res;
}
ChoreoRecord? get choreo {

View file

@ -35,8 +35,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

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();
/// 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 + value.first.constructType.string,
ConstructUses(
uses: value,
constructType: value.first.constructType,
lemma: value.first.lemma!,
),
),
);
}
constructUses.sort((a, b) {
ConstructUses? getConstructUses(String lemma, ConstructTypeEnum type) {
if (_constructMap == null) _buildConstructMap();
return _constructMap![lemma + type.string];
}
/// A list of ConstructUses, each of which contains a lemma and
/// a list of uses, sorted by the number of uses
List<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,7 +76,7 @@ class OneConstructUse {
String? lemma;
String? form;
List<String> categories;
ConstructTypeEnum? constructType;
ConstructTypeEnum constructType;
ConstructUseTypeEnum useType;
String? id;
ConstructUseMetaData metadata;
@ -96,6 +96,11 @@ class OneConstructUse {
DateTime get timeStamp => metadata.timeStamp;
factory OneConstructUse.fromJson(Map<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 +110,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 +120,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 +128,10 @@ class OneConstructUse {
'form': form,
'msgId': metadata.eventId,
};
if (!condensed && lemma != null) data['lemma'] = lemma!;
if (!condensed && constructType != null) {
data['constructType'] = constructType!.string;
}
data['lemma'] = lemma!;
data['constructType'] = constructType.string;
if (id != null) data['id'] = id;
data['categories'] = categories;
return data;

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

@ -145,6 +145,7 @@ class ChoreoRecord {
lemma: name,
form: name,
constructType: ConstructTypeEnum.grammar,
// @ggurdin what is this used for?
id: "${metadata.eventId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}",
metadata: metadata,
),

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';
@ -126,14 +127,47 @@ 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++) {
final PangeaToken token = tokens[i];
token.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) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "reconstructed text does not match original input",
);
}
tokens = newTokens;
//update offsets in existing matches to reflect the change
//Question - remove matches that overlap with the accepted one?
// see case of "quiero ver un fix"
@ -142,18 +176,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 +189,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,43 @@ class PangeaToken {
required this.morph,
});
static String reconstructText(
List<PangeaToken> tokens, [
int startTokenIndex = 0,
int endTokenIndex = -1,
]) {
if (endTokenIndex == -1) {
endTokenIndex = tokens.length - 1;
}
final List<PangeaToken> subset =
tokens.whereIndexed((int index, PangeaToken token) {
return index >= startTokenIndex && index <= endTokenIndex;
}).toList();
if (subset.isEmpty) {
debugger(when: kDebugMode);
return '';
}
if (subset.length == 1) {
return subset.first.text.content;
}
String reconstruction = subset.first.text.content;
for (int i = 1; i < subset.length - 1; i++) {
int whitespace = subset[i].text.offset -
(subset[i - 1].text.offset + subset[i - 1].text.length);
if (whitespace < 0) {
debugger(when: kDebugMode);
whitespace = 0;
}
reconstruction += ' ' * whitespace + subset[i].text.content;
}
return reconstruction;
}
static Lemma _getLemmas(String text, dynamic json) {
if (json != null) {
// July 24, 2024 - we're changing from a list to a single lemma and this is for backwards compatibility
@ -67,7 +108,45 @@ class PangeaToken {
'morph': morph,
};
/// alias for the offset
int get start => text.offset;
/// alias for the end of the token ie offset + length
int get end => text.offset + text.length;
/// create an empty tokenWithXP object
TokenWithXP get emptyTokenWithXP {
final List<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 +175,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,178 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
class ConstructWithXP {
final ConstructIdentifier id;
int xp;
final DateTime? lastUsed;
ConstructWithXP({
required this.id,
required this.xp,
required this.lastUsed,
});
factory ConstructWithXP.fromJson(Map<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() {
return {
'construct_id': id.toJson(),
'xp': xp,
'last_used': lastUsed?.toIso8601String(),
};
}
}
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 MessageActivityRequest {
final String userL1;
final String userL2;
final String messageText;
final List<TokenWithXP> tokensWithXP;
final String messageId;
MessageActivityRequest({
required this.userL1,
required this.userL2,
required this.messageText,
required this.tokensWithXP,
required this.messageId,
});
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,
);
}
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,
};
}
// 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;
MessageActivityResponse({
required this.activity,
required this.finished,
});
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,
);
}
Map<String, dynamic> toJson() {
return {
'activity': activity?.toJson(),
'finished': finished,
};
}
}

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 {
@ -269,6 +284,8 @@ class PracticeActivityModel {
);
}
RelevantSpanDisplayDetails? get relevantSpanDisplayDetails =>
multipleChoice?.spanDisplayDetails;
Map<String, dynamic> toJson() {
return {
'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
@ -282,20 +299,32 @@ class PracticeActivityModel {
};
}
RelevantSpanDisplayDetails? getRelevantSpanDisplayDetails() {
switch (activityType) {
case ActivityTypeEnum.multipleChoice:
return multipleChoice?.spanDisplayDetails;
case ActivityTypeEnum.listening:
return null;
case ActivityTypeEnum.speaking:
return null;
case ActivityTypeEnum.freeResponse:
return null;
default:
debugger(when: kDebugMode);
return null;
}
// override operator == and hashCode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PracticeActivityModel &&
const ListEquality().equals(other.tgtConstructs, tgtConstructs) &&
other.langCode == langCode &&
other.msgId == msgId &&
other.activityType == activityType &&
other.multipleChoice == multipleChoice &&
other.listening == listening &&
other.speaking == speaking &&
other.freeResponse == freeResponse;
}
@override
int get hashCode {
return const ListEquality().hash(tgtConstructs) ^
langCode.hashCode ^
msgId.hashCode ^
activityType.hashCode ^
multipleChoice.hashCode ^
listening.hashCode ^
speaking.hashCode ^
freeResponse.hashCode;
}
}
@ -332,7 +361,23 @@ class RelevantSpanDisplayDetails {
return {
'offset': offset,
'length': length,
'display_instructions': displayInstructions,
'display_instructions': displayInstructions.string,
};
}
// override operator == and hashCode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is RelevantSpanDisplayDetails &&
other.offset == offset &&
other.length == length &&
other.displayInstructions == displayInstructions;
}
@override
int get hashCode {
return offset.hashCode ^ length.hashCode ^ displayInstructions.hashCode;
}
}

View file

@ -63,6 +63,10 @@ class PracticeActivityRecordModel {
: ConstructUseTypeEnum.incPA)
: ConstructUseTypeEnum.unk;
bool hasTextResponse(String text) {
return responses.any((element) => element.text == text);
}
void addResponse({
String? text,
Uint8List? audioBytes,
@ -80,7 +84,7 @@ class PracticeActivityRecordModel {
),
);
} catch (e) {
debugger();
debugger(when: kDebugMode);
}
}

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

@ -54,6 +54,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

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

@ -45,7 +45,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 +56,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':

View file

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart';
@ -5,44 +7,59 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/message.dart';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class MessageSelectionOverlay extends StatefulWidget {
final ChatController controller;
final Event event;
final Event? nextEvent;
final Event? prevEvent;
final PangeaMessageEvent pangeaMessageEvent;
final MessageMode? initialMode;
final MessageTextSelection textSelection;
final ChatController chatController;
late final Event _event;
late final Event? _nextEvent;
late final Event? _prevEvent;
late final PangeaMessageEvent _pangeaMessageEvent;
const MessageSelectionOverlay({
required this.controller,
required this.event,
required this.pangeaMessageEvent,
required this.textSelection,
this.initialMode,
this.nextEvent,
this.prevEvent,
MessageSelectionOverlay({
required this.chatController,
required Event event,
required PangeaMessageEvent pangeaMessageEvent,
required Event? nextEvent,
required Event? prevEvent,
super.key,
});
}) {
_pangeaMessageEvent = pangeaMessageEvent;
_nextEvent = nextEvent;
_prevEvent = prevEvent;
_event = event;
}
@override
MessageSelectionOverlayState createState() => MessageSelectionOverlayState();
MessageOverlayController createState() => MessageOverlayController();
}
class MessageSelectionOverlayState extends State<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
int needed = 3;
/// Whether the user has completed the activities needed to unlock the toolbar
/// within this overlay 'session'. if they click out and come back in then
/// we can give them some more activities to complete
bool finishedActivitiesThisSession = false;
@override
void initState() {
super.initState();
@ -50,8 +67,119 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
vsync: this,
duration: FluffyThemes.animationDuration,
);
setInitialToolbarMode();
}
int get activitiesLeftToComplete =>
needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted;
bool get isPracticeComplete => activitiesLeftToComplete <= 0;
/// In some cases, we need to exit the practice flow and let the user
/// interact with the toolbar without completing activities
void exitPracticeFlow() {
needed = 0;
setInitialToolbarMode();
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 there's no selected span, then select the token
if (_selectedSpan == null) {
_selectedSpan = token.text;
} else {
// if there is a selected span, then deselect the token if it's the same
if (isTokenSelected(token)) {
_selectedSpan = null;
} else {
// if there is a selected span but it is not the same, then select the token
_selectedSpan = token.text;
}
}
setState(() {});
}
void clearSelection() {
_selectedSpan = null;
setState(() {});
}
void onNewActivity(PracticeActivityModel activity) {
final RelevantSpanDisplayDetails? span =
activity.multipleChoice?.spanDisplayDetails;
if (span == null) {
debugger(when: kDebugMode);
return;
}
_selectedSpan = PangeaTokenText(
offset: span.offset,
length: span.length,
content: widget._pangeaMessageEvent.messageDisplayText
.substring(span.offset, span.offset + span.length),
);
setState(() {});
}
/// Whether the given token is currently selected
bool isTokenSelected(PangeaToken token) {
return _selectedSpan?.offset == token.text.offset &&
_selectedSpan?.length == token.text.length;
}
/// Whether the overlay is currently displaying a selection
bool get isSelection => _selectedSpan != null;
PangeaTokenText? get selectedSpan => _selectedSpan;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@ -108,8 +236,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 +251,7 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
}
RenderBox? get messageRenderBox => MatrixState.pAnyState.getRenderBox(
widget.event.eventId,
widget._event.eventId,
);
Size? get messageSize => messageRenderBox?.size;
@ -146,7 +274,7 @@ 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(
constraints: const BoxConstraints(
@ -158,40 +286,38 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: widget.pangeaMessageEvent.ownMessage
mainAxisAlignment: widget._pangeaMessageEvent.ownMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(
left: widget.pangeaMessageEvent.ownMessage
left: widget._pangeaMessageEvent.ownMessage
? 0
: Avatar.defaultSize + 16,
right: widget.pangeaMessageEvent.ownMessage ? 8 : 0,
right: widget._pangeaMessageEvent.ownMessage ? 8 : 0,
),
child: MessageToolbar(
pangeaMessageEvent: widget.pangeaMessageEvent,
controller: widget.controller,
textSelection: widget.textSelection,
initialMode: widget.initialMode,
pangeaMessageEvent: widget._pangeaMessageEvent,
overLayController: this,
),
),
],
),
Message(
widget.event,
widget._event,
onSwipe: () => {},
onInfoTab: (_) => {},
onAvatarTab: (_) => {},
scrollToEventId: (_) => {},
onSelect: (_) => {},
immersionMode: widget.controller.choreographer.immersionMode,
controller: widget.controller,
timeline: widget.controller.timeline!,
isOverlay: true,
immersionMode: widget.chatController.choreographer.immersionMode,
controller: widget.chatController,
timeline: widget.chatController.timeline!,
overlayController: this,
animateIn: false,
nextEvent: widget.nextEvent,
previousEvent: widget.prevEvent,
nextEvent: widget._nextEvent,
previousEvent: widget._prevEvent,
),
],
),
@ -240,7 +366,7 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OverlayFooter(controller: widget.controller),
OverlayFooter(controller: widget.chatController),
],
),
),
@ -252,7 +378,7 @@ class MessageSelectionOverlayState extends State<MessageSelectionOverlay>
),
),
Material(
child: OverlayHeader(controller: widget.controller),
child: OverlayHeader(controller: widget.chatController),
),
],
),

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,5 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart';
@ -7,29 +9,25 @@ import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_speech_to_text_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart';
import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:matrix/matrix.dart';
class MessageToolbar extends StatefulWidget {
final MessageTextSelection textSelection;
final PangeaMessageEvent pangeaMessageEvent;
final ChatController controller;
final MessageMode? initialMode;
final MessageOverlayController overLayController;
const MessageToolbar({
super.key,
required this.textSelection,
required this.pangeaMessageEvent,
required this.controller,
this.initialMode,
required this.overLayController,
});
@override
@ -37,203 +35,113 @@ class MessageToolbar extends StatefulWidget {
}
class MessageToolbarState extends State<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;
}
// why can't this just be initstate or the build mode?
// WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
// //determine the starting mode
// // if (widget.pangeaMessageEvent.isAudioMessage) {
// // updateMode(MessageMode.speechToText);
// // return;
// // }
if (widget.initialMode != null) {
updateMode(widget.initialMode!);
} else {
MatrixState.pangeaController.userController.profile.userSettings
.autoPlayMessages
? updateMode(MessageMode.textToSpeech)
: updateMode(MessageMode.translation);
}
});
// // if (widget.initialMode != null) {
// // updateMode(widget.initialMode!);
// // } else {
// // MatrixState.pangeaController.userController.profile.userSettings
// // .autoPlayMessages
// // ? updateMode(MessageMode.textToSpeech)
// // : updateMode(MessageMode.translation);
// // }
// // });
Timer? timer;
selectionStream =
widget.textSelection.selectionStream.stream.listen((value) {
timer?.cancel();
timer = Timer(const Duration(milliseconds: 500), () {
if (value != null && value.isNotEmpty) {
final MessageMode newMode = currentMode == MessageMode.definition
? MessageMode.definition
: MessageMode.translation;
updateMode(newMode);
} else if (currentMode != null) {
updateMode(currentMode!);
// // just set mode based on messageSelectionOverlay mode which is now handling the state
// updateMode(widget.overLayController.toolbarMode);
// });
}
Widget get toolbarContent {
final bool subscribed =
MatrixState.pangeaController.subscriptionController.isSubscribed;
if (!subscribed) {
return MessageUnsubscribedCard(
languageTool: widget.overLayController.toolbarMode.title(context),
mode: widget.overLayController.toolbarMode,
controller: this,
);
}
switch (widget.overLayController.toolbarMode) {
case MessageMode.translation:
return MessageTranslationCard(
messageEvent: widget.pangeaMessageEvent,
selection: widget.overLayController.selectedSpan,
);
case MessageMode.textToSpeech:
return MessageAudioCard(
messageEvent: widget.pangeaMessageEvent,
);
case MessageMode.speechToText:
return MessageSpeechToTextCard(
messageEvent: widget.pangeaMessageEvent,
);
case MessageMode.definition:
if (!widget.overLayController.isSelection) {
return const SelectToDefine();
} else {
try {
final selectedText = widget.overLayController.targetText;
return WordDataCard(
word: selectedText,
wordLang: widget.pangeaMessageEvent.messageDisplayLangCode,
fullText: widget.pangeaMessageEvent.messageDisplayText,
fullTextLang: widget.pangeaMessageEvent.messageDisplayLangCode,
hasInfo: true,
room: widget.overLayController.widget.chatController.room,
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: "Error in WordDataCard",
s: s,
data: {
"word": widget.overLayController.targetText,
"fullText": widget.pangeaMessageEvent.messageDisplayText,
},
);
return const SizedBox();
}
}
});
});
case MessageMode.practiceActivity:
return PracticeActivityCard(
pangeaMessageEvent: widget.pangeaMessageEvent,
overlayController: widget.overLayController,
);
default:
debugger(when: kDebugMode);
ErrorHandler.logError(
e: "Invalid toolbar mode",
s: StackTrace.current,
data: {"newMode": widget.overLayController.toolbarMode},
);
return const SizedBox();
}
}
@override
void dispose() {
selectionStream.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final buttonRow = Row(
mainAxisSize: MainAxisSize.min,
children: MessageMode.values
.map(
(mode) => mode.isValidMode(widget.pangeaMessageEvent.event)
? Tooltip(
message: mode.tooltip(context),
child: IconButton(
icon: Icon(mode.icon),
color: mode.iconColor(
widget.pangeaMessageEvent,
currentMode,
context,
),
onPressed: () => updateMode(mode),
),
)
: const SizedBox.shrink(),
)
.toList(),
);
debugPrint("building toolbar");
return Material(
key: MatrixState.pAnyState
.layerLinkAndKey('${widget.pangeaMessageEvent.eventId}-toolbar')
@ -245,7 +153,7 @@ class MessageToolbarState extends State<MessageToolbar> {
maxWidth: 275,
minWidth: 275,
),
padding: const EdgeInsets.fromLTRB(20, 20, 20, 10),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
border: Border.all(
@ -259,16 +167,15 @@ class MessageToolbarState extends State<MessageToolbar> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (toolbarContent != null)
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: toolbarContent,
),
Flexible(
child: SingleChildScrollView(
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
child: toolbarContent,
),
),
buttonRow,
),
ToolbarButtons(messageToolbarController: this, width: 250),
],
),
),
@ -296,30 +203,144 @@ class ToolbarSelectionArea extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SelectionArea(
onSelectionChanged: (SelectedContent? selection) {
controller.textSelection.onSelection(selection?.plainText);
return GestureDetector(
onTap: () {
if (pangeaMessageEvent != null && !isOverlay) {
controller.showToolbar(
pangeaMessageEvent!,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
}
},
child: GestureDetector(
onTap: () {
if (pangeaMessageEvent != null && !isOverlay) {
controller.showToolbar(
pangeaMessageEvent!,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
}
},
onLongPress: () {
if (pangeaMessageEvent != null && !isOverlay) {
controller.showToolbar(
pangeaMessageEvent!,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
}
},
child: child,
onLongPress: () {
if (pangeaMessageEvent != null && !isOverlay) {
controller.showToolbar(
pangeaMessageEvent!,
nextEvent: nextEvent,
prevEvent: prevEvent,
);
}
},
child: child,
);
}
}
class ToolbarButtons extends StatefulWidget {
final MessageToolbarState messageToolbarController;
final double width;
const ToolbarButtons({
required this.messageToolbarController,
required this.width,
super.key,
});
@override
ToolbarButtonsState createState() => ToolbarButtonsState();
}
class ToolbarButtonsState extends State<ToolbarButtons> {
PangeaMessageEvent get pangeaMessageEvent =>
widget.messageToolbarController.widget.pangeaMessageEvent;
List<MessageMode> get modes => MessageMode.values
.where((mode) => mode.isValidMode(pangeaMessageEvent.event))
.toList();
static const double iconWidth = 36.0;
double get progressWidth => widget.width / overlayController.needed;
MessageOverlayController get overlayController =>
widget.messageToolbarController.widget.overLayController;
// @ggurdin - maybe this can be stateless now?
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
if (widget
.messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) {
return const SizedBox();
}
return SizedBox(
width: widget.width,
child: Stack(
alignment: Alignment.center,
children: [
Stack(
children: [
Container(
width: widget.width,
height: 12,
decoration: BoxDecoration(
color: MessageModeExtension.barAndLockedButtonColor(context),
),
margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2),
),
AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: 12,
width: min(
widget.width,
progressWidth *
pangeaMessageEvent.numberOfActivitiesCompleted,
),
color: AppConfig.success,
margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: modes
.mapIndexed(
(index, mode) => Tooltip(
message: mode.tooltip(context),
child: IconButton(
iconSize: 20,
icon: Icon(mode.icon),
color: mode ==
widget.messageToolbarController.widget
.overLayController.toolbarMode
? Colors.white
: null,
isSelected: mode ==
widget.messageToolbarController.widget
.overLayController.toolbarMode,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
mode.iconButtonColor(
context,
index,
widget.messageToolbarController.widget
.overLayController.toolbarMode,
pangeaMessageEvent.numberOfActivitiesCompleted,
widget.messageToolbarController.widget
.overLayController.isPracticeComplete,
),
),
),
onPressed: mode.isUnlocked(
index,
pangeaMessageEvent.numberOfActivitiesCompleted,
overlayController.isPracticeComplete,
)
? () => widget
.messageToolbarController.widget.overLayController
.updateToolbarMode(mode)
: null,
),
),
)
.toList(),
),
],
),
);
}

View file

@ -1,11 +1,11 @@
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/repo/full_text_translation_repo.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/inline_tooltip.dart';
import 'package:fluffychat/pangea/widgets/chat/message_text_selection.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -13,13 +13,11 @@ import 'package:flutter/material.dart';
class MessageTranslationCard extends StatefulWidget {
final PangeaMessageEvent messageEvent;
final bool immersionMode;
final MessageTextSelection selection;
final PangeaTokenText? selection;
const MessageTranslationCard({
super.key,
required this.messageEvent,
required this.immersionMode,
required this.selection,
});
@ -30,10 +28,27 @@ class MessageTranslationCard extends StatefulWidget {
class MessageTranslationCardState extends State<MessageTranslationCard> {
PangeaRepresentation? repEvent;
String? selectionTranslation;
String? oldSelectedText;
bool _fetchingRepresentation = false;
bool _fetchingTranslation = false;
Future<void> fetchRepresentation() async {
@override
void initState() {
print('MessageTranslationCard initState');
super.initState();
loadTranslation();
}
@override
void didUpdateWidget(covariant MessageTranslationCard oldWidget) {
// debugger(when: kDebugMode);
if (oldWidget.selection != widget.selection) {
debugPrint('selection changed');
loadTranslation();
}
super.didUpdateWidget(oldWidget);
}
Future<void> fetchRepresentationText() async {
// debugger(when: kDebugMode);
if (l1Code == null) return;
repEvent = widget.messageEvent
@ -49,48 +64,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,27 +114,6 @@ 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(),
@ -144,23 +138,24 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
final bool isTextIdentical = selectionTranslation != null &&
widget.messageEvent.originalSent?.text == selectionTranslation;
return isWrittenInL1 || isTextIdentical;
return (isWrittenInL1 || isTextIdentical) && widget.messageEvent.ownMessage;
}
@override
Widget build(BuildContext context) {
if (!_fetchingRepresentation &&
print('MessageTranslationCard build');
if (!_fetchingTranslation &&
repEvent == null &&
selectionTranslation == null) {
return const CardErrorWidget();
}
return Container(
child: _fetchingRepresentation
child: _fetchingTranslation
? const ToolbarContentLoadingIndicator()
: Column(
children: [
selectionTranslation != null
widget.selection != null
? Text(
selectionTranslation!,
style: BotStyle.text(context),

View file

@ -28,7 +28,7 @@ class MessageUnsubscribedCard extends StatelessWidget {
if (inTrialWindow) {
MatrixState.pangeaController.subscriptionController
.activateNewUserTrial();
controller.updateMode(mode);
controller.widget.overLayController.updateToolbarMode(mode);
} else {
MatrixState.pangeaController.subscriptionController
.showPaywall(context);

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 = () {
print(
'tokenPosition.tokenIndex: ${tokenPosition.tokenIndex}',
);
widget.overlayController.onClickOverlayMessageToken(
tokenPosition.token!,
);
setState(() {});
},
text: tokenPosition.token!.text.content,
style: style.merge(
TextStyle(
backgroundColor: isSelected
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
return TextSpan(
text: widget.pangeaMessageEvent.event.body.substring(
tokenPosition.start,
tokenPosition.end,
),
style: style,
);
}
}).toList(),
),
);
}
}
class TokenPosition {
final int start;
final int end;
final PangeaToken? token;
final int tokenIndex;
const TokenPosition({
required this.start,
required this.end,
this.token,
this.tokenIndex = -1,
});
}

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

@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
@ -142,7 +143,9 @@ class LearningProgressIndicatorsState
final progressBar = ProgressBar(
levelBars: [
LevelBarDetails(
fillColor: const Color.fromARGB(255, 0, 190, 83),
fillColor: kDebugMode
? const Color.fromARGB(255, 0, 190, 83)
: Theme.of(context).colorScheme.primary,
currentPoints: currentXP,
),
LevelBarDetails(

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

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

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

View file

@ -76,6 +76,7 @@ class WordDataCardController extends State<WordDataCard> {
@override
void didUpdateWidget(covariant WordDataCard oldWidget) {
// debugger(when: kDebugMode);
if (oldWidget.word != widget.word) {
if (!widget.hasInfo) {
getContextualDefinition();

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,5 +1,6 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
@ -8,12 +9,12 @@ import 'package:flutter/material.dart';
/// The multiple choice activity view
class MultipleChoiceActivity extends StatefulWidget {
final MessagePracticeActivityCardState controller;
final MessagePracticeActivityCardState practiceCardController;
final PracticeActivityEvent? currentActivity;
const MultipleChoiceActivity({
super.key,
required this.controller,
required this.practiceCardController,
required this.currentActivity,
});
@ -25,7 +26,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
int? selectedChoiceIndex;
PracticeActivityRecordModel? get currentRecordModel =>
widget.controller.currentRecordModel;
widget.practiceCardController.currentCompletionRecord;
bool get isSubmitted =>
widget.currentActivity?.userRecord?.record.latestResponse != null;
@ -52,7 +53,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
/// determines the selected choice index.
void setCompletionRecord() {
if (widget.currentActivity?.userRecord?.record == null) {
widget.controller.setCurrentModel(
widget.practiceCardController.setCompletionRecord(
PracticeActivityRecordModel(
question:
widget.currentActivity?.practiceActivity.multipleChoice!.question,
@ -60,8 +61,8 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
);
selectedChoiceIndex = null;
} else {
widget.controller
.setCurrentModel(widget.currentActivity!.userRecord!.record);
widget.practiceCardController
.setCompletionRecord(widget.currentActivity!.userRecord!.record);
selectedChoiceIndex = widget
.currentActivity?.practiceActivity.multipleChoice!
.choiceIndex(currentRecordModel!.latestResponse!.text!);
@ -69,16 +70,41 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
setState(() {});
}
void updateChoice(int index) {
void updateChoice(String value, int index) {
if (currentRecordModel?.hasTextResponse(value) ?? false) {
return;
}
final bool isCorrect = widget
.currentActivity!.practiceActivity.multipleChoice!
.isCorrect(value, index);
final ConstructUseTypeEnum useType =
isCorrect ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA;
currentRecordModel?.addResponse(
text: widget.controller.currentActivity!.practiceActivity.multipleChoice!
.choices[index],
score: widget.controller.currentActivity!.practiceActivity.multipleChoice!
.isCorrect(index)
? 1
: 0,
text: value,
score: isCorrect ? 1 : 0,
);
// TODO - add draft uses
// activities currently pass around tgtConstructs but not the token
// either we change addDraftUses to take constructs or we get and pass the token
// MatrixState.pangeaController.myAnalytics.addDraftUses(
// widget.currentActivity.practiceActivity.tg,
// widget.practiceCardController.widget.pangeaMessageEvent.room.id,
// useType,
// );
// If the selected choice is correct, send the record and get the next activity
if (widget.currentActivity!.practiceActivity.multipleChoice!
.isCorrect(value, index)) {
widget.practiceCardController.onActivityFinish();
}
setState(
() => selectedChoiceIndex = index,
);
setState(() => selectedChoiceIndex = index);
}
@override
@ -112,10 +138,11 @@ class MultipleChoiceActivityState extends State<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(),

View file

@ -1,25 +1,39 @@
import 'package:fluffychat/config/app_config.dart';
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.dart';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
/// The wrapper for practice activity content.
/// Handles the activities assosiated with a message,
/// Handles the activities associated with a message,
/// their navigation, and the management of completion records
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
});
@override
@ -29,8 +43,10 @@ class PracticeActivityCard extends StatefulWidget {
class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityEvent? currentActivity;
PracticeActivityRecordModel? currentRecordModel;
bool sending = false;
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
List<TokenWithXP> targetTokens = [];
List<PracticeActivityEvent> get practiceActivities =>
widget.pangeaMessageEvent.practiceActivities;
@ -39,148 +55,332 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
(activity) => activity.event.eventId == currentActivity?.event.eventId,
);
bool get isPrevEnabled =>
practiceEventIndex > 0 &&
practiceActivities.length > (practiceEventIndex - 1);
bool get isNextEnabled =>
practiceEventIndex >= 0 &&
practiceEventIndex < practiceActivities.length - 1;
/// TODO - @ggurdin - how can we start our processes (saving results and getting an activity)
/// immediately after a correct choice but wait to display until x milliseconds after the choice is made AND
/// we've received the new activity?
Duration appropriateTimeForJoy = const Duration(milliseconds: 500);
bool savoringTheJoy = false;
Timer? joyTimer;
@override
void initState() {
super.initState();
setCurrentActivity();
initialize();
}
/// Initalizes the current activity.
/// If the current activity hasn't been set yet, show the first
/// uncompleted activity if there is one.
/// If not, show the first activity
void setCurrentActivity() {
if (practiceActivities.isEmpty) return;
void updateFetchingActivity(bool value) {
if (fetchingActivity == value) return;
setState(() => fetchingActivity = value);
}
/// Get an activity to display.
/// Show an uncompleted activity if there is one.
/// If not, get a new activity from the server.
Future<void> initialize() async {
targetTokens = await getTargetTokens();
currentActivity = _fetchExistingActivity() ?? await _fetchNewActivity();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(currentActivity!.practiceActivity);
}
// TODO - do more of a check for whether we have an appropropriate activity
// if the user did the activity before but awhile ago and we don't have any
// more target tokens, maybe we should give them the same activity again
PracticeActivityEvent? _fetchExistingActivity() {
final List<PracticeActivityEvent> incompleteActivities =
practiceActivities.where((element) => !element.isComplete).toList();
currentActivity ??= incompleteActivities.isNotEmpty
? incompleteActivities.first
: practiceActivities.first;
setState(() {});
final PracticeActivityEvent? existingActivity =
incompleteActivities.isNotEmpty ? incompleteActivities.first : null;
return existingActivity != null &&
existingActivity.practiceActivity !=
currentActivity?.practiceActivity
? existingActivity
: null;
}
void setCurrentModel(PracticeActivityRecordModel? recordModel) {
currentRecordModel = recordModel;
}
Future<PracticeActivityEvent?> _fetchNewActivity() async {
updateFetchingActivity(true);
/// Sets the current acitivity based on the given [direction].
void navigateActivities(Direction direction) {
final bool enableNavigation = (direction == Direction.f && isNextEnabled) ||
(direction == Direction.b && isPrevEnabled);
if (enableNavigation) {
currentActivity = practiceActivities[direction == Direction.f
? practiceEventIndex + 1
: practiceEventIndex - 1];
setState(() {});
if (targetTokens.isEmpty ||
!pangeaController.languageController.languagesSet) {
debugger(when: kDebugMode);
updateFetchingActivity(false);
return null;
}
final ourNewActivity =
await pangeaController.practiceGenerationController.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: representation!.text,
tokensWithXP: targetTokens,
messageId: widget.pangeaMessageEvent.eventId,
),
widget.pangeaMessageEvent,
);
/// Removes the target tokens of the new activity from the target tokens list.
/// This avoids getting activities for the same token again, at least
/// until the user exists the toolbar and re-enters it. By then, the
/// analytics stream will have updated and the user will be able to get
/// activity data for previously targeted tokens. This should then exclude
/// the tokens that were targeted in previous activities based on xp and lastUsed.
if (ourNewActivity?.practiceActivity.relevantSpanDisplayDetails != null) {
targetTokens.removeWhere((token) {
final RelevantSpanDisplayDetails span =
ourNewActivity!.practiceActivity.relevantSpanDisplayDetails!;
return token.token.text.offset >= span.offset &&
token.token.text.offset + token.token.text.length <=
span.offset + span.length;
});
}
updateFetchingActivity(false);
return ourNewActivity;
}
/// 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>> getTargetTokens() async {
if (!mounted) {
ErrorHandler.logError(
m: 'getTargetTokens called when not mounted',
s: StackTrace.current,
);
return [];
}
// we're just going to set this once per session
// we remove the target tokens when we get a new activity
if (targetTokens.isNotEmpty) return targetTokens;
if (representation == null) {
debugger(when: kDebugMode);
return [];
}
final tokens = await representation?.tokensGlobal(context);
if (tokens == null || tokens.isEmpty) {
debugger(when: kDebugMode);
return [];
}
var constructUses =
MatrixState.pangeaController.analytics.analyticsStream.value;
if (constructUses == null || constructUses.isEmpty) {
constructUses = [];
//@gurdin - this is happening for me with a brand-new user. however, in this case, constructUses should be empty list
debugger(when: kDebugMode);
}
final ConstructListModel constructList = ConstructListModel(
uses: constructUses,
type: null,
);
final List<TokenWithXP> tokenCounts = [];
// TODO - add morph constructs to this list as well
for (int i = 0; i < tokens.length; i++) {
//don't bother with tokens that we don't save to vocab
if (!tokens[i].lemma.saveVocab) {
continue;
}
tokenCounts.add(tokens[i].emptyTokenWithXP);
for (final construct in tokenCounts.last.constructs) {
final constructUseModel = constructList.getConstructUses(
construct.id.lemma,
construct.id.type,
);
if (constructUseModel != null) {
construct.xp = constructUseModel.points;
}
}
}
tokenCounts.sort((a, b) => a.xp.compareTo(b.xp));
return tokenCounts;
}
void setCompletionRecord(PracticeActivityRecordModel? recordModel) {
currentCompletionRecord = recordModel;
}
/// future that simply waits for the appropriate time to savor the joy
Future<void> savorTheJoy() async {
joyTimer?.cancel();
if (savoringTheJoy) return;
savoringTheJoy = true;
joyTimer = Timer(appropriateTimeForJoy, () {
savoringTheJoy = false;
joyTimer?.cancel();
});
}
/// Sends the current record model and activity to the server.
/// If either the currentRecordModel or currentActivity is null, the method returns early.
/// Sets the [sending] flag to true before sending the record and activity.
/// Logs any errors that occur during the send operation.
/// Sets the [sending] flag to false when the send operation is complete.
void sendRecord() {
if (currentRecordModel == null || currentActivity == null) return;
setState(() => sending = true);
MatrixState.pangeaController.activityRecordController
.send(currentRecordModel!, currentActivity!)
.catchError((error) {
/// If the currentActivity is the last activity, the method sets the appropriate flag to true.
/// If the currentActivity is not the last activity, the method fetches a new activity.
void onActivityFinish() async {
try {
if (currentCompletionRecord == null || currentActivity == null) {
debugger(when: kDebugMode);
return;
}
// start joy timer
savorTheJoy();
// if this is the last activity, set the flag to true
// so we can give them some kudos
if (widget.overlayController.activitiesLeftToComplete == 1) {
widget.overlayController.finishedActivitiesThisSession = true;
}
final Event? event = await MatrixState
.pangeaController.activityRecordController
.send(currentCompletionRecord!, currentActivity!);
MatrixState.pangeaController.myAnalytics.setState(
AnalyticsStream(
eventId: widget.pangeaMessageEvent.eventId,
eventType: PangeaEventTypes.activityRecord,
roomId: event!.room.id,
practiceActivity: currentActivity!,
recordModel: currentCompletionRecord!,
),
);
if (!widget.overlayController.finishedActivitiesThisSession) {
currentActivity = await _fetchNewActivity();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(currentActivity!.practiceActivity);
} else {
updateFetchingActivity(false);
widget.overlayController.setState(() {});
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: error,
s: StackTrace.current,
e: e,
s: s,
m: 'Failed to send record for activity',
data: {
'recordModel': currentRecordModel?.toJson(),
'practiceEvent': currentActivity?.event.toJson(),
'activity': currentActivity,
'record': currentCompletionRecord,
},
);
return null;
}).then((event) {
// The record event is processed into construct uses for learning analytics, so if the
// event went through without error, send it to analytics to be processed
if (event != null && currentActivity != null) {
MatrixState.pangeaController.myAnalytics.setState(
widget.overlayController.exitPracticeFlow();
}
}
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 the user has finished all the activities to unlock the toolbar in this session
if (widget.overlayController.finishedActivitiesThisSession) {
return "Boom! Tools unlocked!";
// if we have no activities to show
} else if (!fetchingActivity && currentActivity == null) {
return L10n.of(context)!.noActivitiesFound;
}
return null;
}
@override
Widget build(BuildContext context) {
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,
if (userMessage != null) {
return Center(
child: Container(
constraints: const BoxConstraints(
minHeight: 80,
),
child: Text(
userMessage!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
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,
// );
}
return Column(
return Stack(
alignment: Alignment.center,
children: [
PracticeActivity(
practiceEvent: currentActivity!,
controller: this,
// Main content
const Positioned(
child: PointsGainedAnimation(),
),
navigationButtons,
Column(
children: [
activityWidget,
// navigationButtons,
],
),
// Conditionally show the darkening and progress indicator based on the loading state
if (!savoringTheJoy && fetchingActivity) ...[
// Semi-transparent overlay
Container(
color: Colors.black.withOpacity(0.5), // Darkening effect
),
// Circular progress indicator in the center
const Center(
child: CircularProgressIndicator(),
),
],
],
);
}

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

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

View file

@ -1305,18 +1305,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05"
sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a"
url: "https://pub.dev"
source: hosted
version: "10.0.5"
version: "10.0.4"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806"
sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.3"
leak_tracker_testing:
dependency: transitive
description:
@ -1417,10 +1417,10 @@ packages:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.8.0"
material_symbols_icons:
dependency: "direct main"
description:
@ -1442,10 +1442,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.12.0"
mgrs_dart:
dependency: transitive
description:
@ -1682,10 +1682,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
version: "3.1.4"
platform_detect:
dependency: transitive
description:
@ -2303,26 +2303,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e"
sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073"
url: "https://pub.dev"
source: hosted
version: "1.25.7"
version: "1.25.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb"
sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
version: "0.7.0"
test_core:
dependency: transitive
description:
name: test_core
sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696"
sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4"
url: "https://pub.dev"
source: hosted
version: "0.6.4"
version: "0.6.0"
timezone:
dependency: transitive
description:
@ -2615,10 +2615,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
version: "14.2.1"
wakelock_plus:
dependency: "direct main"
description: