Merge branch 'main' into blue-error-handling

This commit is contained in:
ggurdin 2024-07-01 15:17:57 -04:00 committed by GitHub
commit a394cece5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
131 changed files with 3267 additions and 56967 deletions

View file

@ -79,5 +79,7 @@ jobs:
with:
name: web
path: build/web
- name: Update packages
run: flutter pub get
- name: Update sentry
run: flutter packages pub run sentry_dart_plugin

View file

@ -2894,21 +2894,23 @@
"type": "text",
"placeholders": {}
},
"helpMeTranslate": "Help me translate!",
"helpMeTranslate": "Yes!",
"@helpMeTranslate": {
"type": "text",
"placeholders": {}
},
"needsItShortMessage": "Try interactive translation!",
"needsItShortMessage": "Out of target",
"needsIGCShortMessage": "Try interactive grammar assistance!",
"@needsItShortMessage": {
"type": "text",
"placeholders": {}
},
"needsItMessage": "This message has too many words in your base language.",
"needsItMessage": "Wait, that's not {targetLanguage}! Do you need help translating?",
"@needsItMessage": {
"type": "text",
"placeholders": {}
"placeholders": {
"targetLanguage": {}
}
},
"needsIgcMessage": "This message has a grammar error.",
"tokenTranslationTitle": "A word is in your base language.",
@ -3109,7 +3111,7 @@
"prettyGood": "Pretty good! Here's what I would have said.",
"letMeThink": "Hmm, let's see how you did!",
"clickMessageTitle": "Need help?",
"clickMessageBody": "Click messages to access definitions, translations, and audio!",
"clickMessageBody": "Click a message for language help! Click and hold to react 😀.",
"understandingMessagesTitle": "Definitions and translations!",
"understandingMessagesBody": "Click underlined words for definitions. Translate with message options (upper right).",
"allDone": "All done!",
@ -3998,6 +4000,10 @@
"conversationBotDiscussionZone_discussionTriggerScheduleHourIntervalLabel": "Hours between discussion prompts",
"conversationBotDiscussionZone_discussionTriggerReactionEnabledLabel": "Responds on ⏩ reaction",
"conversationBotDiscussionZone_discussionTriggerReactionKeyLabel": "Reaction to send discussion prompt",
"conversationBotCustomZone_title": "Custom Settings",
"conversationBotCustomZone_customSystemPromptLabel": "System prompt",
"conversationBotCustomZone_customSystemPromptPlaceholder": "Set custom system prompt",
"conversationBotCustomZone_customTriggerReactionEnabledLabel": "Responds on ⏩ reaction",
"addConversationBotDialogTitleInvite": "Confirm inviting conversation bot",
"addConversationBotButtonInvite": "Invite",
"addConversationBotDialogInviteConfirmation": "Invite",
@ -4010,9 +4016,9 @@
"wordsPerMinute": "Words per minute",
"autoIGCToolName": "Run Language Assistance Automatically",
"autoIGCToolDescription": "Automatically run language assistance after typing messages",
"runGrammarCorrection": "Run grammar correction",
"runGrammarCorrection": "Check message",
"grammarCorrectionFailed": "Issues to address",
"grammarCorrectionComplete": "Grammar correction complete",
"grammarCorrectionComplete": "Looks good!",
"leaveRoomDescription": "The chat will be moved to the archive. Other users will be able to see that you have left the chat.",
"archiveSpaceDescription": "All chats within this space will be moved to the archive for yourself and other non-admin users.",
"leaveSpaceDescription": "All chats within this space will be moved to the archive. Other users will be able to see that you have left the space.",
@ -4058,5 +4064,26 @@
"suggestToSpaceDesc": "Suggested spaces will appear in the chat lists for their parent spaces",
"practice": "Practice",
"noLanguagesSet": "No languages set",
"noActivitiesFound": "No practice activities found for this message"
"noActivitiesFound": "No practice activities found for this message",
"hintTitle": "Hint:",
"speechToTextBody": "See how well you did by looking at your Accuracy and Words Per Minute scores",
"previous": "Previous",
"languageButtonLabel": "Language: {currentLanguage}",
"@languageButtonLabel": {
"type": "text",
"placeholders": {
"currentLanguage": {}
}
},
"interactiveTranslatorAutoPlaySliderHeader": "Autoplay translation",
"@interactiveTranslatorAutoPlaySliderHeader": {
"type": "text",
"placeholders": {}
},
"interactiveTranslatorAutoPlayDesc": "Launches the interactive translator without asking.",
"@interactiveTranslatorAutoPlayDesc": {
"type": "text",
"placeholders": {}
},
"changeAnalyticsView": "Change Analytics View"
}

View file

@ -3064,6 +3064,17 @@
"type": "text",
"placeholders": {}
},
"interactiveTranslatorAutoPlaySliderHeader": "Traducción de reproducción automática",
"interactiveTranslatorAutoPlay": "Traductora interactiva de reproducción automática",
"@interactiveTranslatorAutoPlay": {
"type": "text",
"placeholders": {}
},
"interactiveTranslatorAutoPlayDesc": "Inicia el traductor interactivo sin preguntar.",
"@interactiveTranslatorAutoPlayDesc": {
"type": "text",
"placeholders": {}
},
"grammarAssistance": "Asistencia gramatical",
"@grammarAssistance": {
"type": "text",
@ -3126,10 +3137,16 @@
"translationSeemsFinished": "La traducción parece estar terminada.",
"needsItShortMessage": "¡Pruebe la traducción interactiva!",
"needsIGCShortMessage": "¡Pruebe el corrector gramatical interactivo!",
"needsItMessage": "Este mensaje tiene demasiadas palabras en su idioma base.",
"needsItMessage": "Espera, ¡ese no es {targetLanguage}! ¿Necesitas ayuda para traducir?",
"@needsItMessage": {
"type": "text",
"placeholders": {
"targetLanguage": {}
}
},
"needsIgcMessage": "Este mensaje tiene un error gramatical.",
"tokenTranslationTitle": "Una palabra está en su idioma base.",
"helpMeTranslate": "¡Ayúdeme a traducir!",
"helpMeTranslate": "¡!",
"setToPublicSettingsTitle": "¿Quiere encontrar un compañero de conversación?",
"setToPublicSettingsDesc": "Antes de que pueda buscar un compañero de conversación, debe configurar la visibilidad de su perfil como pública.",
"publicProfileTitle": "Perfil público",
@ -4512,7 +4529,7 @@
"definitions": "definiciones",
"subscribedToUnlockTools": "Suscríbase para desbloquear herramientas lingüísticas, como",
"clickMessageTitle": "¿Necesitas ayuda?",
"clickMessageBody": "Haga clic en los mensajes para acceder a las definiciones, traducciones y audio.",
"clickMessageBody": "¡Lame un mensaje para obtener ayuda con el idioma! Haz clic y mantén presionado para reaccionar 😀",
"more": "Más",
"translationTooltip": "Traducir",
"audioTooltip": "Reproducir audio",
@ -4609,9 +4626,9 @@
"enterNumber": "Introduzca un valor numérico entero.",
"autoIGCToolName": "Ejecutar automáticamente la asistencia lingüística",
"autoIGCToolDescription": "Ejecutar automáticamente la asistencia lingüística después de escribir mensajes",
"runGrammarCorrection": "Corregir la gramática",
"runGrammarCorrection": "Comprobar mensaje",
"grammarCorrectionFailed": "Cuestiones a tratar",
"grammarCorrectionComplete": "Corrección gramatical completa",
"grammarCorrectionComplete": "¡Se ve bien!",
"leaveRoomDescription": "El chat se moverá al archivo. Los demás usuarios podrán ver que has abandonado el chat.",
"archiveSpaceDescription": "Todos los chats de este espacio se moverán al archivo para ti y otros usuarios que no sean administradores.",
"leaveSpaceDescription": "Todos los chats dentro de este espacio se moverán al archivo. Los demás usuarios podrán ver que has abandonado el espacio.",

Binary file not shown.

View file

@ -170,31 +170,11 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const StudentAnalyticsPage(),
const StudentAnalyticsPage(
selectedView: BarChartViewSelection.messages,
),
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: 'messages',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const StudentAnalyticsPage(
selectedView: BarChartViewSelection.messages,
),
),
),
GoRoute(
path: 'errors',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const StudentAnalyticsPage(
selectedView: BarChartViewSelection.grammar,
),
),
),
],
),
GoRoute(
path: 'analytics',
@ -207,34 +187,13 @@ abstract class AppRoutes {
routes: [
GoRoute(
path: ':spaceid',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SpaceAnalyticsPage(),
const SpaceAnalyticsPage(
selectedView: BarChartViewSelection.messages,
),
),
routes: [
GoRoute(
path: 'messages',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SpaceAnalyticsPage(
selectedView: BarChartViewSelection.messages,
),
),
),
GoRoute(
path: 'errors',
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
const SpaceAnalyticsPage(
selectedView: BarChartViewSelection.grammar,
),
),
),
],
),
],
),

View file

@ -16,7 +16,6 @@ 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/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/use_type.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
@ -586,7 +585,6 @@ class ChatController extends State<ChatPageWithRoom>
PangeaMessageTokens? tokensSent,
PangeaMessageTokens? tokensWritten,
ChoreoRecord? choreo,
UseType? useType,
}) async {
// Pangea#
if (sendController.text.trim().isEmpty) return;
@ -630,7 +628,6 @@ class ChatController extends State<ChatPageWithRoom>
tokensSent: tokensSent,
tokensWritten: tokensWritten,
choreo: choreo,
useType: useType,
)
.then(
(String? msgEventId) async {
@ -644,7 +641,6 @@ class ChatController extends State<ChatPageWithRoom>
GoogleAnalytics.sendMessage(
room.id,
room.classCode,
useType ?? UseType.un,
);
if (msgEventId == null) {

View file

@ -4,8 +4,8 @@ import 'package:fluffychat/pages/chat/events/message.dart';
import 'package:fluffychat/pages/chat/seen_by_row.dart';
import 'package:fluffychat/pages/chat/typing_indicators.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/utils/instructions.dart';
import 'package:fluffychat/pangea/widgets/chat/locked_chat_message.dart';
import 'package:fluffychat/utils/account_config.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
@ -46,7 +46,7 @@ class ChatEventList extends StatelessWidget {
// card, attach it on top of the first shown message
WidgetsBinding.instance.addPostFrameCallback((_) {
if (events.isEmpty) return;
controller.pangeaController.instructions.show(
controller.pangeaController.instructions.showInstructionsPopup(
context,
InstructionsEnum.clickMessage,
events[0].eventId,

View file

@ -1,9 +1,8 @@
import 'package:animations/animations.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart';
import 'package:fluffychat/pangea/choreographer/widgets/send_button.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -55,9 +54,6 @@ class ChatInputRow extends StatelessWidget {
return Column(
children: [
ITBar(
choreographer: controller.choreographer,
),
Row(
// crossAxisAlignment: CrossAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,

View file

@ -24,6 +24,7 @@ import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import '../../pangea/choreographer/widgets/it_bar.dart';
import '../../utils/stream_extension.dart';
import 'chat_emoji_picker.dart';
import 'chat_input_row.dart';
@ -440,6 +441,9 @@ class ChatView extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
const ConnectionStatusHeader(),
ITBar(
choreographer: controller.choreographer,
),
ReactionsPicker(controller),
ReplyDisplay(controller),
ChatInputRow(controller),

View file

@ -470,7 +470,7 @@ class Message extends StatelessWidget {
?.showUseType ??
false) ...[
pangeaMessageEvent!
.useType
.msgUseType
.iconView(
context,
textColor

File diff suppressed because it is too large Load diff

View file

@ -917,7 +917,7 @@ class ChatListController extends State<ChatList>
if (mounted) {
GoogleAnalytics.analyticsUserUpdate(client.userID);
await pangeaController.subscriptionController.initialize();
await pangeaController.myAnalytics.addEventsListener();
await pangeaController.myAnalytics.initialize();
pangeaController.afterSyncAndFirstLoginInitialization(context);
await pangeaController.inviteBotToExistingSpaces();
await pangeaController.setPangeaPushRules();

View file

@ -35,11 +35,26 @@ class ChatPermissionsSettingsView extends StatelessWidget {
final powerLevels = Map<String, dynamic>.from(powerLevelsContent)
// #Pangea
// ..removeWhere((k, v) => v is! int);
..removeWhere((k, v) => v is! int || k.equals("m.call.invite"));
..removeWhere(
(k, v) =>
v is! int ||
k.equals("m.call.invite") ||
k.equals("historical") ||
k.equals("state_default"),
);
// Pangea#
final eventsPowerLevels = Map<String, int?>.from(
powerLevelsContent.tryGetMap<String, int?>('events') ?? {},
)..removeWhere((k, v) => v is! int);
// #Pangea
)..removeWhere(
(k, v) =>
v is! int ||
k.equals("m.space.child") ||
k.equals("pangea.usranalytics") ||
k.equals(EventTypes.RoomPowerLevels),
);
// )..removeWhere((k, v) => v is! int);
// Pangea#
return Column(
children: [
Column(
@ -57,51 +72,59 @@ class ChatPermissionsSettingsView extends StatelessWidget {
),
canEdit: room.canChangePowerLevel,
),
Divider(color: Theme.of(context).dividerColor),
ListTile(
title: Text(
L10n.of(context)!.notifications,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
Builder(
builder: (context) {
const key = 'rooms';
final value = powerLevelsContent
.containsKey('notifications')
? powerLevelsContent
.tryGetMap<String, Object?>('notifications')
?.tryGet<int>('rooms') ??
0
: 0;
return PermissionsListTile(
permissionKey: key,
permission: value,
category: 'notifications',
canEdit: room.canChangePowerLevel,
onChanged: (level) => controller.editPowerLevel(
context,
key,
value,
newLevel: level,
category: 'notifications',
// #Pangea
// Why would teacher need to stop students from seeing notifications?
// Divider(color: Theme.of(context).dividerColor),
// ListTile(
// title: Text(
// L10n.of(context)!.notifications,
// style: TextStyle(
// color: Theme.of(context).colorScheme.primary,
// fontWeight: FontWeight.bold,
// ),
// ),
// ),
// Builder(
// builder: (context) {
// const key = 'rooms';
// final value = powerLevelsContent
// .containsKey('notifications')
// ? powerLevelsContent
// .tryGetMap<String, Object?>('notifications')
// ?.tryGet<int>('rooms') ??
// 0
// : 0;
// return PermissionsListTile(
// permissionKey: key,
// permission: value,
// category: 'notifications',
// canEdit: room.canChangePowerLevel,
// onChanged: (level) => controller.editPowerLevel(
// context,
// key,
// value,
// newLevel: level,
// category: 'notifications',
// ),
// );
// },
// ),
// Only show if there are actually items in this category
if (eventsPowerLevels.isNotEmpty)
// Pangea#
Divider(color: Theme.of(context).dividerColor),
// #Pangea
if (eventsPowerLevels.isNotEmpty)
// Pangea#
ListTile(
title: Text(
L10n.of(context)!.configureChat,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
);
},
),
Divider(color: Theme.of(context).dividerColor),
ListTile(
title: Text(
L10n.of(context)!.configureChat,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
for (final entry in eventsPowerLevels.entries)
PermissionsListTile(
permissionKey: entry.key,

View file

@ -5,15 +5,16 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/choreographer/controllers/alternative_translator.dart';
import 'package:fluffychat/pangea/choreographer/controllers/igc_controller.dart';
import 'package:fluffychat/pangea/choreographer/controllers/message_options.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
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/language_detection_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';
import 'package:fluffychat/pangea/models/user_model.dart';
import 'package:fluffychat/pangea/utils/any_state_holder.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/overlay.dart';
@ -23,7 +24,6 @@ import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../../widgets/matrix.dart';
import '../../enum/use_type.dart';
import '../../models/choreo_record.dart';
import '../../models/language_model.dart';
import '../../models/pangea_match_model.dart';
@ -93,63 +93,59 @@ class Choreographer {
}
Future<void> _sendWithIGC(BuildContext context) async {
if (igc.canSendMessage) {
final PangeaRepresentation? originalWritten =
choreoRecord.includedIT && itController.sourceText != null
? PangeaRepresentation(
langCode: l1LangCode ?? LanguageKeys.unknownLanguage,
text: itController.sourceText!,
originalWritten: true,
originalSent: false,
)
: null;
final PangeaRepresentation originalSent = PangeaRepresentation(
langCode: langCodeOfCurrentText ?? LanguageKeys.unknownLanguage,
text: currentText,
originalSent: true,
originalWritten: originalWritten == null,
);
final ChoreoRecord? applicableChoreo =
isITandIGCEnabled && igc.igcTextData != null ? choreoRecord : null;
// if the message has not been processed to determine its language
// then run it through the language detection endpoint. If the detection
// confidence is high enough, use that language code as the message's language
// to save that pangea representation
if (applicableChoreo == null) {
final resp = await pangeaController.languageDetection.detectLanguage(
currentText,
pangeaController.languageController.userL2?.langCode,
pangeaController.languageController.userL1?.langCode,
);
final LanguageDetection? bestDetection = resp.bestDetection();
if (bestDetection != null) {
originalSent.langCode = bestDetection.langCode;
}
}
final UseType useType = useTypeCalculator(applicableChoreo);
debugPrint("use type in choreographer $useType");
chatController.send(
// PTODO - turn this back on in conjunction with saving tokens
// we need to save those tokens as well, in order for exchanges to work
// properly. in an exchange, the other user will want
// originalWritten: originalWritten,
originalSent: originalSent,
tokensSent: igc.igcTextData?.tokens != null
? PangeaMessageTokens(tokens: igc.igcTextData!.tokens)
: null,
//TODO - save originalwritten tokens
choreo: applicableChoreo,
useType: useType,
);
clear();
} else {
if (!igc.canSendMessage) {
igc.showFirstMatch(context);
return;
}
final PangeaRepresentation? originalWritten =
choreoRecord.includedIT && itController.sourceText != null
? PangeaRepresentation(
langCode: l1LangCode ?? LanguageKeys.unknownLanguage,
text: itController.sourceText!,
originalWritten: true,
originalSent: false,
)
: null;
// 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
// notes
// 1) we probably need to move this to after we clear the input field
// or the user could experience some lag here.
// 2) that this call is being made after we've determined if we have an applicable choreo in order to
// say whether correction was run on the message. we may eventually want
// to edit the useType after
if (igc.igcTextData?.tokens == null ||
igc.igcTextData?.detectedLanguage == null) {
await igc.getIGCTextData(onlyTokensAndLanguageDetection: true);
}
final PangeaRepresentation originalSent = PangeaRepresentation(
langCode:
igc.igcTextData?.detectedLanguage ?? LanguageKeys.unknownLanguage,
text: currentText,
originalSent: true,
originalWritten: originalWritten == null,
);
final PangeaMessageTokens? tokensSent = igc.igcTextData?.tokens != null
? PangeaMessageTokens(tokens: igc.igcTextData!.tokens)
: null;
chatController.send(
// originalWritten: originalWritten,
originalSent: originalSent,
tokensSent: tokensSent,
//TODO - save originalwritten tokens
// choreo: applicableChoreo,
choreo: choreoRecord,
);
clear();
}
_resetDebounceTimer() {
@ -165,7 +161,7 @@ class Choreographer {
}
choreoMode = ChoreoMode.it;
itController.initializeIT(
ITStartData(_textController.text, igc.detectedLangCode),
ITStartData(_textController.text, igc.igcTextData?.detectedLanguage),
);
itMatch.status = PangeaMatchStatus.accepted;
@ -178,6 +174,7 @@ class Choreographer {
_textController.setSystemText("", EditType.itStart);
}
/// Handles any changes to the text input
_onChangeListener() {
if (_noChange) {
return;
@ -186,21 +183,26 @@ class Choreographer {
if ([
EditType.igc,
].contains(_textController.editType)) {
// this may be unnecessary now that tokens are not used
// to allow click of words in the input field and we're getting this at the end
// TODO - turn it off and tested that this is fine
igc.justGetTokensAndAddThemToIGCTextData();
// we set editType to keyboard here because that is the default for it
// and we want to make sure that the next change is treated as a keyboard change
// unless the system explicity sets it to something else. this
textController.editType = EditType.keyboard;
return;
}
// not sure if this is necessary now
MatrixState.pAnyState.closeOverlay();
if (errorService.isError) {
return;
}
// if (igc.igcTextData != null) {
igc.clear();
// setState();
// }
_resetDebounceTimer();
@ -210,7 +212,9 @@ class Choreographer {
() => getLanguageHelp(),
);
} else {
getLanguageHelp(ChoreoMode.it == choreoMode);
getLanguageHelp(
onlyTokensAndLanguageDetection: ChoreoMode.it == choreoMode,
);
}
//Note: we don't set the keyboard type on each keyboard stroke so this is how we default to
@ -219,10 +223,14 @@ class Choreographer {
textController.editType = EditType.keyboard;
}
Future<void> getLanguageHelp([
bool tokensOnly = false,
/// Fetches the language help for the current text, including grammar correction, language detection,
/// tokens, and translations. Includes logic to exit the flow if the user is not subscribed, if the tools are not enabled, or
/// or if autoIGC is not enabled and the user has not manually requested it.
/// [onlyTokensAndLanguageDetection] will
Future<void> getLanguageHelp({
bool onlyTokensAndLanguageDetection = false,
bool manual = false,
]) async {
}) async {
try {
if (errorService.isError) return;
final CanSendStatus canSendStatus =
@ -237,13 +245,15 @@ class Choreographer {
startLoading();
if (choreoMode == ChoreoMode.it &&
itController.isTranslationDone &&
!tokensOnly) {
!onlyTokensAndLanguageDetection) {
// debugger(when: kDebugMode);
}
await (choreoMode == ChoreoMode.it && !itController.isTranslationDone
? itController.getTranslationData(_useCustomInput)
: igc.getIGCTextData(tokensOnly: tokensOnly));
: igc.getIGCTextData(
onlyTokensAndLanguageDetection: onlyTokensAndLanguageDetection,
));
} catch (err, stack) {
ErrorHandler.logError(e: err, s: stack);
} finally {
@ -480,14 +490,6 @@ class Choreographer {
bool get editTypeIsKeyboard => EditType.keyboard == _textController.editType;
String? get langCodeOfCurrentText {
if (igc.detectedLangCode != null) return igc.detectedLangCode!;
if (itController.isOpen) return l2LangCode!;
return null;
}
setState() {
if (!stateListener.isClosed) {
stateListener.add(0);
@ -521,6 +523,12 @@ class Choreographer {
chatController.room,
);
bool get itAutoPlayEnabled =>
pangeaController.pStoreService.read(
MatrixProfile.itAutoPlay.title,
) ??
false;
bool get definitionsEnabled =>
pangeaController.permissionsController.isToolEnabled(
ToolSetting.definitions,
@ -570,13 +578,3 @@ class Choreographer {
return AssistanceState.complete;
}
}
// assistance state is, user has not typed a message, user has typed a message and IGC has not run,
// IGC is running, IGC has run and there are remaining steps (either IT or IGC), or all steps are done
enum AssistanceState {
noMessage,
notFetched,
fetching,
fetched,
complete,
}

View file

@ -3,18 +3,17 @@ import 'dart:developer';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/choreographer/controllers/error_service.dart';
import 'package:fluffychat/pangea/controllers/span_data_controller.dart';
import 'package:fluffychat/pangea/choreographer/controllers/span_data_controller.dart';
import 'package:fluffychat/pangea/models/igc_text_data_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/repo/igc_repo.dart';
import 'package:fluffychat/pangea/repo/tokens_repo.dart';
import 'package:fluffychat/pangea/widgets/igc/span_card.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../models/language_detection_model.dart';
import '../../models/span_card_model.dart';
import '../../repo/tokens_repo.dart';
import '../../utils/error_handler.dart';
import '../../utils/overlay.dart';
@ -29,59 +28,42 @@ class IgcController {
spanDataController = SpanDataController(choreographer);
}
Future<void> getIGCTextData({required bool tokensOnly}) async {
Future<void> getIGCTextData({
required bool onlyTokensAndLanguageDetection,
}) async {
try {
if (choreographer.currentText.isEmpty) return clear();
// the error spans are going to be reloaded, so clear the cache
spanDataController.clearCache();
debugPrint('getIGCTextData called with ${choreographer.currentText}');
debugPrint('getIGCTextData called with tokensOnly = $tokensOnly');
debugPrint(
'getIGCTextData called with tokensOnly = $onlyTokensAndLanguageDetection',
);
final IGCRequestBody reqBody = IGCRequestBody(
fullText: choreographer.currentText,
userL1: choreographer.l1LangCode!,
userL2: choreographer.l2LangCode!,
enableIGC: choreographer.igcEnabled && !tokensOnly,
enableIT: choreographer.itEnabled && !tokensOnly,
tokensOnly: tokensOnly,
enableIGC: choreographer.igcEnabled && !onlyTokensAndLanguageDetection,
enableIT: choreographer.itEnabled && !onlyTokensAndLanguageDetection,
);
final IGCTextData igcTextDataResponse = await IgcRepo.getIGC(
await choreographer.accessToken,
igcRequest: reqBody,
);
// temp fix
igcTextDataResponse.originalInput = reqBody.fullText;
//this will happen when the user changes the input while igc is fetching results
// this will happen when the user changes the input while igc is fetching results
if (igcTextDataResponse.originalInput != choreographer.currentText) {
// final current = choreographer.currentText;
// final igctext = igcTextDataResponse.originalInput;
// Sentry.addBreadcrumb(
// Breadcrumb(message: "igc return input does not match current text"),
// );
// debugger(when: kDebugMode);
return;
}
//TO-DO: in api call, specify turning off IT and/or grammar checking
if (!choreographer.igcEnabled) {
igcTextDataResponse.matches = igcTextDataResponse.matches
.where((match) => !match.isGrammarMatch)
.toList();
}
if (!choreographer.itEnabled) {
igcTextDataResponse.matches = igcTextDataResponse.matches
.where((match) => !match.isOutOfTargetMatch)
.toList();
}
if (!choreographer.itEnabled && !choreographer.igcEnabled) {
igcTextDataResponse.matches = [];
}
igcTextData = igcTextDataResponse;
// TODO - for each new match,
// check if existing igcTextData has one and only one match with the same error text and correction
// if so, keep the original match and discard the new one
// if not, add the new match to the existing igcTextData
// After fetching igc data, pre-call span details for each match optimistically.
// This will make the loading of span details faster for the user
if (igcTextData?.matches.isNotEmpty ?? false) {
@ -170,6 +152,13 @@ class IgcController {
const int firstMatchIndex = 0;
final PangeaMatch match = igcTextData!.matches[firstMatchIndex];
if (match.isITStart &&
choreographer.itAutoPlayEnabled &&
igcTextData != null) {
choreographer.onITStart(igcTextData!.matches[firstMatchIndex]);
return;
}
OverlayUtil.showPositionedCard(
context: context,
cardToShow: SpanCard(
@ -189,7 +178,7 @@ class IgcController {
),
roomId: choreographer.roomId,
),
cardSize: match.isITStart ? const Size(350, 220) : const Size(350, 400),
cardSize: match.isITStart ? const Size(350, 260) : const Size(350, 400),
transformTargetId: choreographer.inputTransformTargetKey,
);
}
@ -206,14 +195,6 @@ class IgcController {
return true;
}
String? get detectedLangCode {
if (!hasRelevantIGCTextData) return null;
final LanguageDetection first = igcTextData!.detections.first;
return first.langCode;
}
clear() {
igcTextData = null;
spanDataController.clearCache();

View file

@ -21,6 +21,7 @@ class ITController {
Choreographer choreographer;
bool _isOpen = false;
bool _willOpen = false;
bool _isEditingSourceText = false;
bool showChoiceFeedback = false;
@ -36,6 +37,7 @@ class ITController {
void clear() {
_isOpen = false;
_willOpen = false;
showChoiceFeedback = false;
_isEditingSourceText = false;
@ -54,6 +56,7 @@ class ITController {
}
Future<void> initializeIT(ITStartData itStartData) async {
_willOpen = true;
Future.delayed(const Duration(microseconds: 100), () {
_isOpen = true;
});
@ -61,17 +64,14 @@ class ITController {
}
void closeIT() {
//if they close it before choosing anything, just put their text back
//if they close it before completing, just put their text back
//PTODO - explore using last itStep
if (choreographer.currentText.isEmpty) {
choreographer.textController.text = sourceText ?? "";
}
choreographer.textController.text = sourceText ?? "";
clear();
}
/// if IGC isn't positive that text is full L1 then translate to L1
Future<void> _setSourceText() async {
// try {
if (_itStartData == null || _itStartData!.text.isEmpty) {
Sentry.addBreadcrumb(
Breadcrumb(
@ -96,21 +96,12 @@ class ITController {
request: FullTextTranslationRequestModel(
text: _itStartData!.text,
tgtLang: choreographer.l1LangCode!,
srcLang: choreographer.l2LangCode,
srcLang: _itStartData!.langCode,
userL1: choreographer.l1LangCode!,
userL2: choreographer.l2LangCode!,
),
);
sourceText = res.bestTranslation;
// } catch (err, stack) {
// debugger(when: kDebugMode);
// if (_itStartData?.text.isNotEmpty ?? false) {
// ErrorHandler.logError(e: err, s: stack);
// sourceText = _itStartData!.text;
// } else {
// rethrow;
// }
// }
}
// used 1) at very beginning (with custom input = null)
@ -166,7 +157,7 @@ class ITController {
if (isTranslationDone) {
choreographer.altTranslator.setTranslationFeedback();
choreographer.getLanguageHelp(true);
choreographer.getLanguageHelp(onlyTokensAndLanguageDetection: true);
} else {
getNextTranslationData();
}
@ -217,31 +208,17 @@ class ITController {
Future<void> onEditSourceTextSubmit(String newSourceText) async {
try {
sourceText = newSourceText;
_isOpen = true;
_isEditingSourceText = false;
final String currentText = choreographer.currentText;
_itStartData = ITStartData(newSourceText, choreographer.l1LangCode);
completedITSteps = [];
currentITStep = null;
nextITStep = null;
goldRouteTracker = GoldRouteTracker.defaultTracker;
payLoadIds = [];
choreographer.startLoading();
final List<ITResponseModel> responses = await Future.wait([
_customInputTranslation(""),
_customInputTranslation(choreographer.currentText),
]);
if (responses[0].goldContinuances != null &&
responses[0].goldContinuances!.isNotEmpty) {
goldRouteTracker = GoldRouteTracker(
responses[0].goldContinuances!,
sourceText!,
);
}
currentITStep = CurrentITStep(
sourceText: sourceText!,
currentText: currentText,
responseModel: responses[1],
storedGoldContinuances: goldRouteTracker.continuances,
);
_addPayloadId(responses[1]);
_setSourceText();
getTranslationData(false);
} catch (err, stack) {
debugger(when: kDebugMode);
if (err is! http.Response) {
@ -252,6 +229,7 @@ class ITController {
);
} finally {
choreographer.stopLoading();
choreographer.textController.text = "";
}
}
@ -334,15 +312,14 @@ class ITController {
bool get isOpen => _isOpen;
bool get willOpen => _willOpen;
String get targetLangCode => choreographer.l2LangCode!;
String get sourceLangCode => choreographer.l1LangCode!;
bool get isLoading => choreographer.isFetching;
bool get correctChoicesSelected =>
completedITSteps.every((ITStep step) => step.isCorrect);
String latestChoiceFeedback(BuildContext context) =>
completedITSteps.isNotEmpty
? completedITSteps.last.choiceFeedback(context)

View file

@ -1,7 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';

View file

@ -8,7 +8,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../utils/bot_style.dart';
import 'it_shimmer.dart';
class ChoicesArray extends StatelessWidget {
class ChoicesArray extends StatefulWidget {
final bool isLoading;
final List<Choice>? choices;
final void Function(int) onPressed;
@ -32,23 +32,51 @@ class ChoicesArray extends StatelessWidget {
this.onLongPress,
});
@override
ChoicesArrayState createState() => ChoicesArrayState();
}
class ChoicesArrayState extends State<ChoicesArray> {
bool interactionDisabled = false;
void disableInteraction() {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
interactionDisabled = true;
});
});
}
void enableInteractions() {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
interactionDisabled = false;
});
});
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return isLoading && (choices == null || choices!.length <= 1)
? ItShimmer(originalSpan: originalSpan)
return widget.isLoading &&
(widget.choices == null || widget.choices!.length <= 1)
? ItShimmer(originalSpan: widget.originalSpan)
: Wrap(
alignment: WrapAlignment.center,
children: choices
children: widget.choices
?.asMap()
.entries
.map(
(entry) => ChoiceItem(
theme: theme,
onLongPress: isActive ? onLongPress : null,
onPressed: isActive ? onPressed : (_) {},
onLongPress:
widget.isActive ? widget.onLongPress : null,
onPressed: widget.isActive ? widget.onPressed : (_) {},
entry: entry,
isSelected: selectedChoiceIndex == entry.key,
interactionDisabled: interactionDisabled,
enableInteraction: enableInteractions,
disableInteraction: disableInteraction,
isSelected: widget.selectedChoiceIndex == entry.key,
),
)
.toList() ??
@ -77,6 +105,9 @@ class ChoiceItem extends StatelessWidget {
required this.onPressed,
required this.entry,
required this.isSelected,
required this.interactionDisabled,
required this.enableInteraction,
required this.disableInteraction,
});
final MapEntry<int, Choice> entry;
@ -84,6 +115,9 @@ class ChoiceItem extends StatelessWidget {
final void Function(int p1)? onLongPress;
final void Function(int p1) onPressed;
final bool isSelected;
final bool interactionDisabled;
final VoidCallback enableInteraction;
final VoidCallback disableInteraction;
@override
Widget build(BuildContext context) {
@ -97,6 +131,8 @@ class ChoiceItem extends StatelessWidget {
key: ValueKey(entry.value.text),
selected: entry.value.color != null,
isGold: entry.value.isGold,
enableInteraction: enableInteraction,
disableInteraction: disableInteraction,
child: Container(
margin: const EdgeInsets.all(2),
padding: EdgeInsets.zero,
@ -130,9 +166,11 @@ class ChoiceItem extends StatelessWidget {
),
),
),
onLongPress:
onLongPress != null ? () => onLongPress!(entry.key) : null,
onPressed: () => onPressed(entry.key),
onLongPress: onLongPress != null && !interactionDisabled
? () => onLongPress!(entry.key)
: null,
onPressed:
interactionDisabled ? null : () => onPressed(entry.key),
child: Text(
entry.value.text,
style: BotStyle.text(context),
@ -152,11 +190,15 @@ class ChoiceAnimationWidget extends StatefulWidget {
final Widget child;
final bool selected;
final bool isGold;
final VoidCallback enableInteraction;
final VoidCallback disableInteraction;
const ChoiceAnimationWidget({
super.key,
required this.child,
required this.selected,
required this.enableInteraction,
required this.disableInteraction,
this.isGold = false,
});
@ -164,11 +206,13 @@ class ChoiceAnimationWidget extends StatefulWidget {
ChoiceAnimationWidgetState createState() => ChoiceAnimationWidgetState();
}
enum AnimationState { ready, forward, reverse, finished }
class ChoiceAnimationWidgetState extends State<ChoiceAnimationWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
bool animationPlayed = false;
AnimationState animationState = AnimationState.ready;
@override
void initState() {
@ -196,17 +240,29 @@ class ChoiceAnimationWidgetState extends State<ChoiceAnimationWidget>
),
]).animate(_controller);
if (widget.selected && !animationPlayed) {
widget.enableInteraction();
if (widget.selected && animationState == AnimationState.ready) {
widget.disableInteraction();
_controller.forward();
animationPlayed = true;
setState(() {});
setState(() {
animationState = AnimationState.forward;
});
}
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (status == AnimationStatus.completed &&
animationState == AnimationState.forward) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
_controller.stop();
_controller.reset();
setState(() {
animationState = AnimationState.reverse;
});
}
if (status == AnimationStatus.dismissed &&
animationState == AnimationState.reverse) {
widget.enableInteraction();
setState(() {
animationState = AnimationState.finished;
});
}
});
}
@ -214,10 +270,12 @@ class ChoiceAnimationWidgetState extends State<ChoiceAnimationWidget>
@override
void didUpdateWidget(ChoiceAnimationWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selected && !animationPlayed) {
if (widget.selected && animationState == AnimationState.ready) {
widget.disableInteraction();
_controller.forward();
animationPlayed = true;
setState(() {});
setState(() {
animationState = AnimationState.forward;
});
}
}

View file

@ -7,6 +7,7 @@ import 'package:fluffychat/pangea/choreographer/widgets/it_feedback_card.dart';
import 'package:fluffychat/pangea/choreographer/widgets/translation_finished_flow.dart';
import 'package:fluffychat/pangea/constants/choreo_constants.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -21,93 +22,107 @@ class ITBar extends StatelessWidget {
final Choreographer choreographer;
const ITBar({super.key, required this.choreographer});
ITController get controller => choreographer.itController;
ITController get itController => choreographer.itController;
@override
Widget build(BuildContext context) {
if (!controller.isOpen) return const SizedBox();
return CompositedTransformTarget(
link: choreographer.itBarLinkAndKey.link,
child: Container(
key: choreographer.itBarLinkAndKey.key,
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.black,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppConfig.borderRadius),
topRight: Radius.circular(AppConfig.borderRadius),
),
),
width: double.infinity,
padding: const EdgeInsets.fromLTRB(0, 3, 3, 3),
child: Stack(
children: [
SingleChildScrollView(
child: Column(
return AnimatedSize(
duration: itController.willOpen
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
curve: Curves.fastOutSlowIn,
clipBehavior: Clip.none,
child: !itController.willOpen
? const SizedBox()
: CompositedTransformTarget(
link: choreographer.itBarLinkAndKey.link,
child: AnimatedOpacity(
duration: itController.willOpen
? const Duration(milliseconds: 2000)
: const Duration(milliseconds: 500),
opacity: itController.willOpen ? 1.0 : 0.0,
child: Container(
key: choreographer.itBarLinkAndKey.key,
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.white
: Colors.black,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppConfig.borderRadius),
topRight: Radius.circular(AppConfig.borderRadius),
),
),
width: double.infinity,
padding: const EdgeInsets.fromLTRB(0, 3, 3, 3),
child: Stack(
children: [
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Row(
// // mainAxisAlignment: MainAxisAlignment.start,
// // crossAxisAlignment: CrossAxisAlignment.start,
// // children: [
// // CounterDisplay(
// // correct: controller.correctChoices,
// // custom: controller.customChoices,
// // incorrect: controller.incorrectChoices,
// // yellow: controller.wildcardChoices,
// // ),
// // CompositedTransformTarget(
// // link: choreographer.itBotLayerLinkAndKey.link,
// // child: ITBotButton(
// // key: choreographer.itBotLayerLinkAndKey.key,
// // choreographer: choreographer,
// // ),
// // ),
// // ],
// // ),
// ITCloseButton(choreographer: choreographer),
// ],
// ),
// const SizedBox(height: 40.0),
OriginalText(controller: controller),
const SizedBox(height: 7.0),
IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 80),
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Center(
child: controller.choreographer.errorService.isError
? ITError(
error: controller
SingleChildScrollView(
child: Column(
children: [
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// // Row(
// // mainAxisAlignment: MainAxisAlignment.start,
// // crossAxisAlignment: CrossAxisAlignment.start,
// // children: [
// // CounterDisplay(
// // correct: controller.correctChoices,
// // custom: controller.customChoices,
// // incorrect: controller.incorrectChoices,
// // yellow: controller.wildcardChoices,
// // ),
// // CompositedTransformTarget(
// // link: choreographer.itBotLayerLinkAndKey.link,
// // child: ITBotButton(
// // key: choreographer.itBotLayerLinkAndKey.key,
// // choreographer: choreographer,
// // ),
// // ),
// // ],
// // ),
// ITCloseButton(choreographer: choreographer),
// ],
// ),
// const SizedBox(height: 40.0),
OriginalText(controller: itController),
const SizedBox(height: 7.0),
IntrinsicHeight(
child: Container(
constraints: const BoxConstraints(minHeight: 80),
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: Center(
child: itController.choreographer.errorService.isError
? ITError(
error: itController
.choreographer.errorService.error!,
controller: controller,
controller: itController,
)
: controller.showChoiceFeedback
? ChoiceFeedbackText(controller: controller)
: controller.isTranslationDone
? TranslationFeedback(
controller: controller,
)
: ITChoices(controller: controller),
),
: itController.showChoiceFeedback
? ChoiceFeedbackText(controller: itController)
: itController.isTranslationDone
? TranslationFeedback(
controller: itController,
)
: ITChoices(controller: itController),
),
),
),
],
),
),
Positioned(
top: 0.0,
right: 0.0,
child: ITCloseButton(choreographer: choreographer),
),
],
),
),
Positioned(
top: 0.0,
right: 0.0,
child: ITCloseButton(choreographer: choreographer),
),
],
),
),
),
);
}
@ -184,10 +199,23 @@ class OriginalText extends StatelessWidget {
),
),
),
if (!controller.isEditingSourceText && controller.sourceText != null)
IconButton(
onPressed: () => controller.setIsEditingSourceText(true),
icon: const Icon(Icons.edit_outlined),
if (
!controller.isEditingSourceText
&& controller.sourceText != null
)
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: controller.nextITStep != null
? 1.0
: 0.0,
child: IconButton(
onPressed: () => {
if (controller.nextITStep != null) {
controller.setIsEditingSourceText(true),
},
},
icon: const Icon(Icons.edit_outlined),
),
),
],
),

View file

@ -1,8 +1,8 @@
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/utils/instructions.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../widgets/common/bot_face_svg.dart';
import '../controllers/choreographer.dart';
import '../controllers/it_controller.dart';
@ -37,7 +37,7 @@ class ITBotButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
choreographer.pangeaController.instructions.show(
choreographer.pangeaController.instructions.showInstructionsPopup(
context,
InstructionsEnum.itInstructions,
choreographer.itBotTransformTargetKey,
@ -45,8 +45,9 @@ class ITBotButton extends StatelessWidget {
);
return IconButton(
icon: const BotFace(width: 40.0, expression: BotExpression.right),
onPressed: () => choreographer.pangeaController.instructions.show(
icon: const BotFace(width: 40.0, expression: BotExpression.idle),
onPressed: () =>
choreographer.pangeaController.instructions.showInstructionsPopup(
context,
InstructionsEnum.itInstructions,
choreographer.itBotTransformTargetKey,

View file

@ -129,7 +129,7 @@ class ITFeedbackCardView extends StatelessWidget {
children: [
CardHeader(
text: controller.widget.req.chosenContinuance,
botExpression: BotExpression.down,
botExpression: BotExpression.nonGold,
),
Text(
controller.widget.choiceFeedback,

View file

@ -1,10 +1,9 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/constants/colors.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/enum/assistance_state_enum.dart';
import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -54,15 +53,15 @@ class StartIGCButtonState extends State<StartIGCButton>
setState(() => prevState = assistanceState);
}
bool get itEnabled => widget.controller.choreographer.itEnabled;
bool get igcEnabled => widget.controller.choreographer.igcEnabled;
CanSendStatus get canSendStatus =>
widget.controller.pangeaController.subscriptionController.canSendStatus;
bool get grammarCorrectionEnabled =>
(itEnabled || igcEnabled) && canSendStatus == CanSendStatus.subscribed;
@override
Widget build(BuildContext context) {
final bool itEnabled = widget.controller.choreographer.itEnabled;
final bool igcEnabled = widget.controller.choreographer.igcEnabled;
final CanSendStatus canSendStatus =
widget.controller.pangeaController.subscriptionController.canSendStatus;
final bool grammarCorrectionEnabled =
(itEnabled || igcEnabled) && canSendStatus == CanSendStatus.subscribed;
if (!grammarCorrectionEnabled ||
widget.controller.choreographer.isAutoIGCEnabled ||
widget.controller.choreographer.choreoMode == ChoreoMode.it) {
@ -89,11 +88,11 @@ class StartIGCButtonState extends State<StartIGCButton>
disabledElevation: 0,
shape: const CircleBorder(),
onPressed: () {
if (assistanceState != AssistanceState.complete) {
if (assistanceState != AssistanceState.fetching) {
widget.controller.choreographer
.getLanguageHelp(
false,
true,
onlyTokensAndLanguageDetection: false,
manual: true,
)
.then((_) {
if (widget.controller.choreographer.igc.igcTextData != null &&
@ -142,32 +141,3 @@ class StartIGCButtonState extends State<StartIGCButton>
);
}
}
extension AssistanceStateExtension on AssistanceState {
Color stateColor(context) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
case AssistanceState.fetching:
return Theme.of(context).colorScheme.primary;
case AssistanceState.fetched:
return PangeaColors.igcError;
case AssistanceState.complete:
return AppConfig.success;
}
}
String tooltip(L10n l10n) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
return l10n.runGrammarCorrection;
case AssistanceState.fetching:
return "";
case AssistanceState.fetched:
return l10n.grammarCorrectionFailed;
case AssistanceState.complete:
return l10n.grammarCorrectionComplete;
}
}
}

View file

@ -5,7 +5,7 @@ class Environment {
DateTime.utc(2023, 1, 25).isBefore(DateTime.now());
static String get fileName {
return ".env";
return ".local_choreo.env";
}
static bool get isStaging => synapsURL.contains("staging");

View file

@ -1,4 +0,0 @@
class PrefKey {
static const lastFetched = 'LAST_FETCHED';
static const flags = 'flags';
}

View file

@ -0,0 +1,24 @@
import 'package:fluffychat/pangea/models/language_detection_model.dart';
class LanguageKeys {
static const unknownLanguage = "unk";
static const mixedLanguage = "mixed";
static const defaultLanguage = "en";
static const multiLanguage = "multi";
}
class LanguageLevelType {
static List<int> get allInts => [0, 1, 2, 3, 4, 5, 6];
}
class PrefKey {
static const lastFetched = 'p_lang_lastfetched';
static const flags = 'p_lang_flag';
}
final LanguageDetection unknownLanguageDetection = LanguageDetection(
langCode: LanguageKeys.unknownLanguage,
confidence: 0.5,
);
const double languageDetectionConfidenceThreshold = 0.95;

View file

@ -1,6 +0,0 @@
class LanguageKeys {
static const unknownLanguage = "unk";
static const mixedLanguage = "mixed";
static const defaultLanguage = "en";
static const multiLanguage = "multi";
}

View file

@ -1,3 +0,0 @@
class LanguageLevelType {
static List<int> get allInts => [0, 1, 2, 3, 4, 5, 6];
}

View file

@ -1,4 +0,0 @@
class PrefKey {
static const lastFetched = 'p_lang_lastfetched';
static const flags = 'p_lang_flag';
}

View file

@ -11,5 +11,6 @@ class PLocalKey {
static const String dismissedPaywall = 'dismissedPaywall';
static const String paywallBackoff = 'paywallBackoff';
static const String autoPlayMessages = 'autoPlayMessages';
static const String itAutoPlay = 'itAutoPlay';
static const String messagesSinceUpdate = 'messagesSinceLastUpdate';
}

View file

@ -66,7 +66,6 @@ class ModelKey {
static const String tokensSent = "tokens_sent";
static const String tokensWritten = "tokens_written";
static const String choreoRecord = "choreo_record";
static const String useType = "use_type";
static const String baseDefinition = "base_definition";
static const String targetDefinition = "target_definition";
@ -95,17 +94,16 @@ class ModelKey {
static const String languageLevel = "difficulty";
static const String safetyModeration = "safety_moderation";
static const String mode = "mode";
static const String custom = "custom";
static const String discussionTopic = "discussion_topic";
static const String discussionKeywords = "discussion_keywords";
static const String discussionTriggerScheduleEnabled =
"discussion_trigger_schedule_enabled";
static const String discussionTriggerScheduleHourInterval =
"discussion_trigger_schedule_hour_interval";
static const String discussionTriggerReactionEnabled =
"discussion_trigger_reaction_enabled";
static const String discussionTriggerReactionKey =
"discussion_trigger_reaction_key";
static const String customSystemPrompt = "custom_system_prompt";
static const String customTriggerReactionEnabled =
"custom_trigger_reaction_enabled";
static const String customTriggerReactionKey = "custom_trigger_reaction_key";
static const String prevEventId = "prev_event_id";
static const String prevLastUpdated = "prev_last_updated";

View file

@ -26,7 +26,13 @@ class PangeaEventTypes {
static const String report = 'm.report';
static const textToSpeechRule = "p.rule.text_to_speech";
static const pangeaActivityRes = "pangea.activity_res";
static const acitivtyRequest = "pangea.activity_req";
/// A request to the server to generate activities
static const activityRequest = "pangea.activity_req";
/// A practice activity that is related to a message
static const pangeaActivity = "pangea.activity_res";
/// A record of completion of an activity. There
/// can be one per user per activity.
static const activityRecord = "pangea.activity_completion";
}

View file

@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/language_model.dart';

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/language_detection_model.dart';
import 'package:fluffychat/pangea/network/urls.dart';
@ -75,19 +76,21 @@ class LanguageDetectionResponse {
};
}
LanguageDetection? get _bestDetection {
/// Return the highest confidence detection.
/// If there are no detections, the unknown language detection is returned.
LanguageDetection get highestConfidenceDetection {
detections.sort((a, b) => b.confidence.compareTo(a.confidence));
return detections.isNotEmpty ? detections.first : null;
return detections.firstOrNull ?? unknownLanguageDetection;
}
final double _confidenceThreshold = 0.95;
LanguageDetection? bestDetection({double? threshold}) {
threshold ??= _confidenceThreshold;
return (_bestDetection?.confidence ?? 0) >= _confidenceThreshold
? _bestDetection!
: null;
}
/// Returns the highest validated detection based on the confidence threshold.
/// If the highest confidence detection is below the threshold, the unknown language
/// detection is returned.
LanguageDetection highestValidatedDetection({double? threshold}) =>
highestConfidenceDetection.confidence >=
(threshold ?? languageDetectionConfidenceThreshold)
? highestConfidenceDetection
: unknownLanguageDetection;
}
class _LanguageDetectionCacheItem {

View file

@ -1,13 +1,12 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/repo/language_repo.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import '../constants/language_list_keys.dart';
import '../utils/shared_prefs.dart';
class PangeaLanguage {
@ -27,7 +26,7 @@ class PangeaLanguage {
static Future<void> initialize() async {
try {
_langList = await _getCahedFlags();
_langList = await _getCachedFlags();
if (await _shouldFetch || _langList.isEmpty) {
_langList = await LanguageRepo.fetchLanguages();
@ -77,7 +76,7 @@ class PangeaLanguage {
await MyShared.saveJson(PrefKey.flags, flagMap);
}
static Future<List<LanguageModel>> _getCahedFlags() async {
static Future<List<LanguageModel>> _getCachedFlags() async {
final Map<dynamic, dynamic>? flagsMap =
await MyShared.readJson(PrefKey.flags);
if (flagsMap == null) {

View file

@ -62,12 +62,13 @@ class AnalyticsController extends BaseController {
timeSpan.toString(),
local: true,
);
setState();
}
///////// SPACE ANALYTICS LANGUAGES //////////
String get _analyticsSpaceLangKey => "ANALYTICS_SPACE_LANG_KEY";
LanguageModel get currentAnalyticsSpaceLang {
LanguageModel get currentAnalyticsLang {
try {
final String? str = _pangeaController.pStoreService.read(
_analyticsSpaceLangKey,
@ -83,41 +84,43 @@ class AnalyticsController extends BaseController {
}
}
Future<void> setCurrentAnalyticsSpaceLang(LanguageModel lang) async {
Future<void> setCurrentAnalyticsLang(LanguageModel lang) async {
await _pangeaController.pStoreService.save(
_analyticsSpaceLangKey,
lang.langCode,
local: true,
);
setState();
}
/// given an analytics event type and the current analytics language,
/// get the last time the user updated their analytics
Future<DateTime?> myAnalyticsLastUpdated(String type) async {
// given an analytics event type, get the last updated times
// for each of the user's analytics rooms and return the most recent
// Most Recent instead of the oldest because, for instance:
// My last Spanish event was sent 3 days ago.
// My last English event was sent 1 day ago.
// When I go to check if the cached data is out of date, the cached item was set 2 days ago.
// I know theres new data available because the English update data (the most recent) is after the caches creation time.
// So, I should update the cache.
final List<Room> analyticsRooms = _pangeaController
.matrixState.client.allMyAnalyticsRooms
.where((room) => room.isAnalyticsRoom)
.toList();
final List<DateTime> lastUpdates = [];
final Map<String, DateTime> langCodeLastUpdates = {};
for (final Room analyticsRoom in analyticsRooms) {
final String? roomLang = analyticsRoom.madeForLang;
if (roomLang == null) continue;
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
type,
_pangeaController.matrixState.client.userID!,
);
if (lastUpdated != null) {
lastUpdates.add(lastUpdated);
langCodeLastUpdates[roomLang] = lastUpdated;
}
}
if (lastUpdates.isEmpty) return null;
return lastUpdates.reduce(
if (langCodeLastUpdates.isEmpty) return null;
final String? l2Code =
_pangeaController.languageController.userL2?.langCode;
if (l2Code != null && langCodeLastUpdates.containsKey(l2Code)) {
return langCodeLastUpdates[l2Code];
}
return langCodeLastUpdates.values.reduce(
(check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent,
);
}
@ -134,7 +137,7 @@ class AnalyticsController extends BaseController {
final List<Future<DateTime?>> lastUpdatedFutures = [];
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(currentAnalyticsSpaceLang.langCode, student.id);
.analyticsRoomLocal(currentAnalyticsLang.langCode, student.id);
if (analyticsRoom == null) continue;
lastUpdatedFutures.add(
analyticsRoom.analyticsLastUpdated(
@ -177,28 +180,20 @@ class AnalyticsController extends BaseController {
//////////////////////////// MESSAGE SUMMARY ANALYTICS ////////////////////////////
/// get all the summary analytics events for the current user
/// in the current language's analytics room
Future<List<SummaryAnalyticsEvent>> mySummaryAnalytics() async {
// gets all the summary analytics events for the user
// since the current timespace's cut off date
final analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(currentAnalyticsLang.langCode);
if (analyticsRoom == null) return [];
final List<SummaryAnalyticsEvent> allEvents = [];
// TODO switch to using list of futures
for (final Room analyticsRoom in analyticsRooms) {
final List<AnalyticsEvent>? roomEvents =
await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: _pangeaController.matrixState.client.userID!,
);
allEvents.addAll(
roomEvents?.cast<SummaryAnalyticsEvent>() ?? [],
);
}
return allEvents;
final List<AnalyticsEvent>? roomEvents =
await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: _pangeaController.matrixState.client.userID!,
);
return roomEvents?.cast<SummaryAnalyticsEvent>() ?? [];
}
Future<List<SummaryAnalyticsEvent>> spaceMemberAnalytics(
@ -216,7 +211,7 @@ class AnalyticsController extends BaseController {
final List<SummaryAnalyticsEvent> analyticsEvents = [];
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(currentAnalyticsSpaceLang.langCode, student.id);
.analyticsRoomLocal(currentAnalyticsLang.langCode, student.id);
if (analyticsRoom != null) {
final List<AnalyticsEvent>? roomEvents =
@ -261,7 +256,7 @@ class AnalyticsController extends BaseController {
(e.defaultSelected.type == defaultSelected.type) &&
(e.selected?.id == selected?.id) &&
(e.selected?.type == selected?.type) &&
(e.langCode == currentAnalyticsSpaceLang.langCode),
(e.langCode == currentAnalyticsLang.langCode),
);
if (index != -1) {
@ -289,7 +284,7 @@ class AnalyticsController extends BaseController {
chartAnalyticsModel: chartAnalyticsModel,
defaultSelected: defaultSelected,
selected: selected,
langCode: currentAnalyticsSpaceLang.langCode,
langCode: currentAnalyticsLang.langCode,
),
);
}
@ -525,20 +520,18 @@ class AnalyticsController extends BaseController {
//////////////////////////// CONSTRUCTS ////////////////////////////
Future<List<ConstructAnalyticsEvent>> allMyConstructs() async {
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(currentAnalyticsLang.langCode);
if (analyticsRoom == null) return [];
final List<ConstructAnalyticsEvent> allConstructs = [];
for (final Room analyticsRoom in analyticsRooms) {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: _pangeaController.matrixState.client.userID!,
))
?.cast<ConstructAnalyticsEvent>();
allConstructs.addAll(roomEvents ?? []);
}
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
since: currentAnalyticsTimeSpan.cutOffDate,
userId: _pangeaController.matrixState.client.userID!,
))
?.cast<ConstructAnalyticsEvent>();
final List<ConstructAnalyticsEvent> allConstructs = roomEvents ?? [];
final List<String> adminSpaceRooms =
await _pangeaController.matrixState.client.teacherRoomIds;
@ -561,7 +554,7 @@ class AnalyticsController extends BaseController {
final List<ConstructAnalyticsEvent> constructEvents = [];
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(currentAnalyticsSpaceLang.langCode, student.id);
.analyticsRoomLocal(currentAnalyticsLang.langCode, student.id);
if (analyticsRoom != null) {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
@ -648,7 +641,7 @@ class AnalyticsController extends BaseController {
List<ConstructAnalyticsEvent>? getConstructsLocal({
required TimeSpan timeSpan,
required ConstructType constructType,
required ConstructTypeEnum constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
DateTime? lastUpdated,
@ -661,7 +654,7 @@ class AnalyticsController extends BaseController {
e.defaultSelected.type == defaultSelected.type &&
e.selected?.id == selected?.id &&
e.selected?.type == selected?.type &&
e.langCode == currentAnalyticsSpaceLang.langCode,
e.langCode == currentAnalyticsLang.langCode,
);
if (index > -1) {
@ -676,7 +669,7 @@ class AnalyticsController extends BaseController {
}
void cacheConstructs({
required ConstructType constructType,
required ConstructTypeEnum constructType,
required List<ConstructAnalyticsEvent> events,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
@ -687,14 +680,14 @@ class AnalyticsController extends BaseController {
events: List.from(events),
defaultSelected: defaultSelected,
selected: selected,
langCode: currentAnalyticsSpaceLang.langCode,
langCode: currentAnalyticsLang.langCode,
);
_cachedConstructs.add(entry);
}
Future<List<ConstructAnalyticsEvent>> getMyConstructs({
required AnalyticsSelected defaultSelected,
required ConstructType constructType,
required ConstructTypeEnum constructType,
AnalyticsSelected? selected,
}) async {
final List<ConstructAnalyticsEvent> unfilteredConstructs =
@ -713,7 +706,7 @@ class AnalyticsController extends BaseController {
}
Future<List<ConstructAnalyticsEvent>> getSpaceConstructs({
required ConstructType constructType,
required ConstructTypeEnum constructType,
required Room space,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
@ -775,7 +768,7 @@ class AnalyticsController extends BaseController {
}
Future<List<ConstructAnalyticsEvent>?> getConstructs({
required ConstructType constructType,
required ConstructTypeEnum constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool removeIT = true,
@ -905,7 +898,7 @@ abstract class CacheEntry {
}
class ConstructCacheEntry extends CacheEntry {
final ConstructType type;
final ConstructTypeEnum type;
final List<ConstructAnalyticsEvent> events;
ConstructCacheEntry({

View file

@ -1,15 +1,12 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -18,53 +15,72 @@ import 'package:matrix/matrix.dart';
import '../extensions/client_extension/client_extension.dart';
import '../extensions/pangea_room_extension/pangea_room_extension.dart';
// controls the sending of analytics events
class MyAnalyticsController extends BaseController {
/// 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 {
late PangeaController _pangeaController;
Timer? _updateTimer;
/// the max number of messages that will be cached before
/// an automatic update is triggered
final int _maxMessagesCached = 10;
/// the number of minutes before an automatic update is triggered
final int _minutesBeforeUpdate = 5;
/// the time since the last update that will trigger an automatic update
final Duration _timeSinceUpdate = const Duration(days: 1);
MyAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
// adds the listener that handles when to run automatic updates
// to analytics - either after a certain number of messages sent/
// received or after a certain amount of time without an update
Future<void> addEventsListener() async {
final Client client = _pangeaController.matrixState.client;
/// adds the listener that handles when to run automatic updates
/// to analytics - either after a certain number of messages sent
/// received or after a certain amount of time [_timeSinceUpdate] without an update
Future<void> initialize() async {
final lastUpdated = await _refreshAnalyticsIfOutdated();
// if analytics haven't been updated in the last day, update them
DateTime? lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
final DateTime yesterday = DateTime.now().subtract(const Duration(days: 1));
if (lastUpdated?.isBefore(yesterday) ?? true) {
debugPrint("analytics out-of-date, updating");
await updateAnalytics();
lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
}
client.onSync.stream
// listen for new messages and updateAnalytics timer
// we are doing this in an attempt to update analytics when activitiy is low
// both in messages sent by this client and other clients that you're connected with
// doesn't account for messages sent by other clients that you're not connected with
_client.onSync.stream
.where((SyncUpdate update) => update.rooms?.join != null)
.listen((update) {
updateAnalyticsTimer(update, lastUpdated);
});
}
// given an update from sync stream, check if the update contains
// messages for which analytics will be saved. If so, reset the timer
// and add the event ID to the cache of un-added event IDs
/// If analytics haven't been updated in the last day, update them
Future<DateTime?> _refreshAnalyticsIfOutdated() async {
DateTime? lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate);
if (lastUpdated?.isBefore(yesterday) ?? true) {
debugPrint("analytics out-of-date, updating");
await updateAnalytics();
lastUpdated = await _pangeaController.analytics
.myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics);
}
return lastUpdated;
}
Client get _client => _pangeaController.matrixState.client;
/// Given an update from sync stream, check if the update contains
/// messages for which analytics will be saved. If so, reset the timer
/// and add the event ID to the cache of un-added event IDs
void updateAnalyticsTimer(SyncUpdate update, DateTime? lastUpdated) {
for (final entry in update.rooms!.join!.entries) {
final Room room =
_pangeaController.matrixState.client.getRoomById(entry.key)!;
final Room room = _client.getRoomById(entry.key)!;
// get the new events in this sync that are messages
final List<Event>? events = entry.value.timeline?.events
?.map((event) => Event.fromMatrixEvent(event, room))
.where((event) => eventHasAnalytics(event, lastUpdated))
.where((event) => hasUserAnalyticsToCache(event, lastUpdated))
.toList();
// add their event IDs to the cache of un-added event IDs
@ -84,8 +100,9 @@ class MyAnalyticsController extends BaseController {
}
// checks if event from sync update is a message that should have analytics
bool eventHasAnalytics(Event event, DateTime? lastUpdated) {
return (lastUpdated == null || event.originServerTs.isAfter(lastUpdated)) &&
bool hasUserAnalyticsToCache(Event event, DateTime? lastUpdated) {
return event.senderId == _client.userID &&
(lastUpdated == null || event.originServerTs.isAfter(lastUpdated)) &&
event.type == EventTypes.Message &&
event.messageType == MessageTypes.Text &&
!(event.eventId.contains("web") &&
@ -160,6 +177,7 @@ class MyAnalyticsController extends BaseController {
_updateCompleter = Completer<void>();
try {
await _updateAnalytics();
clearMessagesSinceUpdate();
} catch (err, s) {
ErrorHandler.logError(
e: err,
@ -172,182 +190,135 @@ class MyAnalyticsController extends BaseController {
}
}
String? get userL2 => _pangeaController.languageController.activeL2Code();
/// top level analytics sending function. Gather recent messages and activity records,
/// convert them into the correct formats, and send them to the analytics room
Future<void> _updateAnalytics() async {
// if the user's l2 is not sent, don't send analytics
final String? userL2 = _pangeaController.languageController.activeL2Code();
if (userL2 == null) {
// if missing important info, don't send analytics
if (userL2 == null || _client.userID == null) {
debugger(when: kDebugMode);
return;
}
// top level analytics sending function. Send analytics
// for each type of analytics event
// to each of the applicable analytics rooms
clearMessagesSinceUpdate();
// analytics room for the user and current target language
final Room analyticsRoom = await _client.getMyAnalyticsRoom(userL2!);
// fetch a list of all the chats that the user is studying
// and a list of all the spaces in which the user is studying
await setStudentChats();
await setStudentSpaces();
// get the last updated time for each analytics room
// and the least recent update, which will be used to determine
// how far to go back in the chat history to get messages
final Map<String, DateTime?> lastUpdatedMap = await _pangeaController
.matrixState.client
.allAnalyticsRoomsLastUpdated();
final List<DateTime> lastUpdates = lastUpdatedMap.values
.where((lastUpdate) => lastUpdate != null)
.cast<DateTime>()
.toList();
lastUpdates.sort((a, b) => a.compareTo(b));
final DateTime? leastRecentUpdate =
lastUpdates.isNotEmpty ? lastUpdates.first : null;
// for each chat the user is studying in, get all the messages
// since the least recent update analytics update, and sort them
// by their langCodes
final Map<String, List<PangeaMessageEvent>> langCodeToMsgs =
await getLangCodesToMsgs(
userL2,
leastRecentUpdate,
// get the last time analytics were updated for this room
final DateTime? l2AnalyticsLastUpdated =
await analyticsRoom.analyticsLastUpdated(
PangeaEventTypes.summaryAnalytics,
_client.userID!,
);
final List<String> langCodes = langCodeToMsgs.keys.toList();
for (final String langCode in langCodes) {
// for each of the langs that the user has sent message in, get
// the corresponding analytics room (or create it)
final Room analyticsRoom = await _pangeaController.matrixState.client
.getMyAnalyticsRoom(langCode);
// all chats in which user is a student
final List<Room> chats = _client.rooms
.where((room) => !room.isSpace && !room.isAnalyticsRoom)
.toList();
// if there is no analytics room for this langCode, then user hadn't sent
// message in this language at the time of the last analytics update
// so fallback to the least recent update time
final DateTime? lastUpdated =
lastUpdatedMap[analyticsRoom.id] ?? leastRecentUpdate;
// get the corresponding list of recent messages for this langCode
final List<PangeaMessageEvent> recentMsgs =
langCodeToMsgs[langCode] ?? [];
// finally, send the analytics events to the analytics room
await sendAnalyticsEvents(
analyticsRoom,
recentMsgs,
lastUpdated,
// get the recent message events and activity records for each chat
final List<Future<List<Event>>> recentMsgFutures = [];
final List<Future<List<Event>>> recentActivityFutures = [];
for (final Room chat in chats) {
recentMsgFutures.add(
chat.getEventsBySender(
type: EventTypes.Message,
sender: _client.userID!,
since: l2AnalyticsLastUpdated,
),
);
recentActivityFutures.add(
chat.getEventsBySender(
type: PangeaEventTypes.activityRecord,
sender: _client.userID!,
since: l2AnalyticsLastUpdated,
),
);
}
}
final List<List<Event>> recentMsgs =
(await Future.wait(recentMsgFutures)).toList();
final List<PracticeActivityRecordEvent> recentActivityRecords =
(await Future.wait(recentActivityFutures))
.expand((e) => e)
.map((event) => PracticeActivityRecordEvent(event: event))
.toList();
Future<Map<String, List<PangeaMessageEvent>>> getLangCodesToMsgs(
String userL2,
DateTime? since,
) async {
// get a map of langCodes to messages for each chat the user is studying in
final Map<String, List<PangeaMessageEvent>> langCodeToMsgs = {};
for (final Room chat in _studentChats) {
List<PangeaMessageEvent>? recentMsgs;
try {
recentMsgs = await chat.myMessageEventsInChat(
since: since,
);
} catch (err) {
debugPrint("failed to fetch messages for chat ${chat.id}");
continue;
}
// sort those messages by their langCode
// langCode is hopefully based on the original sent rep, but if that
// is null or unk, it will be based on the user's current l2
for (final msg in recentMsgs) {
final String msgLangCode = (msg.originalSent?.langCode != null &&
msg.originalSent?.langCode != LanguageKeys.unknownLanguage)
? msg.originalSent!.langCode
: userL2;
langCodeToMsgs[msgLangCode] ??= [];
langCodeToMsgs[msgLangCode]!.add(msg);
}
// get the timelines for each chat
final List<Future<Timeline>> timelineFutures = [];
for (final chat in chats) {
timelineFutures.add(chat.getTimeline());
}
return langCodeToMsgs;
}
final List<Timeline> timelines = await Future.wait(timelineFutures);
final Map<String, Timeline> timelineMap =
Map.fromIterables(chats.map((e) => e.id), timelines);
Future<void> sendAnalyticsEvents(
Room analyticsRoom,
List<PangeaMessageEvent> recentMsgs,
DateTime? lastUpdated,
) async {
// remove messages that were sent before the last update
if (recentMsgs.isEmpty) return;
if (lastUpdated != null) {
recentMsgs.removeWhere(
(msg) => msg.event.originServerTs.isBefore(lastUpdated),
//convert into PangeaMessageEvents
final List<List<PangeaMessageEvent>> recentPangeaMessageEvents = [];
for (final (index, eventList) in recentMsgs.indexed) {
recentPangeaMessageEvents.add(
eventList
.map(
(event) => PangeaMessageEvent(
event: event,
timeline: timelines[index],
ownMessage: true,
),
)
.toList(),
);
}
// format the analytics data
final List<PangeaMessageEvent> allRecentMessages =
recentPangeaMessageEvents.expand((e) => e).toList();
final List<RecentMessageRecord> summaryContent =
SummaryAnalyticsModel.formatSummaryContent(recentMsgs);
final List<OneConstructUse> constructContent =
ConstructAnalyticsModel.formatConstructsContent(recentMsgs);
SummaryAnalyticsModel.formatSummaryContent(allRecentMessages);
// if there's new content to be sent, or if lastUpdated hasn't been
// set yet for this room, send the analytics events
if (summaryContent.isNotEmpty || lastUpdated == null) {
await SummaryAnalyticsEvent.sendSummaryAnalyticsEvent(
analyticsRoom,
if (summaryContent.isNotEmpty || l2AnalyticsLastUpdated == null) {
await analyticsRoom.sendSummaryAnalyticsEvent(
summaryContent,
);
}
if (constructContent.isNotEmpty) {
await ConstructAnalyticsEvent.sendConstructsEvent(
analyticsRoom,
constructContent,
// get constructs for messages
final List<OneConstructUse> recentConstructUses = [];
for (final PangeaMessageEvent message in allRecentMessages) {
recentConstructUses.addAll(message.allConstructUses);
}
// get constructs for practice activities
final List<Future<List<OneConstructUse>>> constructFutures = [];
for (final PracticeActivityRecordEvent activity in recentActivityRecords) {
final Timeline? timeline = timelineMap[activity.event.roomId!];
if (timeline == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null timeline",
data: activity.event.toJson(),
);
continue;
}
constructFutures.add(activity.uses(timeline));
}
final List<List<OneConstructUse>> constructLists =
await Future.wait(constructFutures);
recentConstructUses.addAll(constructLists.expand((e) => e));
//TODO - confirm that this is the correct construct content
// debugger(
// when: kDebugMode,
// );
// ; debugger(
// when: kDebugMode &&
// (allRecentMessages.isNotEmpty || recentActivityRecords.isNotEmpty),
// );
if (recentConstructUses.isNotEmpty) {
await analyticsRoom.sendConstructsEvent(
recentConstructUses,
);
}
}
List<Room> _studentChats = [];
Future<void> setStudentChats() async {
final List<String> teacherRoomIds =
await _pangeaController.matrixState.client.teacherRoomIds;
_studentChats = _pangeaController.matrixState.client.rooms
.where(
(r) =>
!r.isSpace &&
!r.isAnalyticsRoom &&
!teacherRoomIds.contains(r.id),
)
.toList();
setState(data: _studentChats);
}
List<Room> get studentChats {
try {
if (_studentChats.isNotEmpty) return _studentChats;
setStudentChats();
return _studentChats;
} catch (err) {
debugger(when: kDebugMode);
return [];
}
}
List<Room> _studentSpaces = [];
Future<void> setStudentSpaces() async {
_studentSpaces =
await _pangeaController.matrixState.client.spacesImStudyingIn;
}
List<Room> get studentSpaces {
try {
if (_studentSpaces.isNotEmpty) return _studentSpaces;
setStudentSpaces();
return _studentSpaces;
} catch (err) {
debugger(when: kDebugMode);
return [];
}
}
}

View file

@ -51,7 +51,7 @@ class PracticeGenerationController {
final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent(
content: model.toJson(),
parentEventId: pangeaMessageEvent.eventId,
type: PangeaEventTypes.pangeaActivityRes,
type: PangeaEventTypes.pangeaActivity,
);
if (activityEvent == null) {
@ -88,7 +88,7 @@ class PracticeGenerationController {
PracticeActivityModel dummyModel(PangeaMessageEvent event) =>
PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(lemma: "be", type: ConstructType.vocab),
ConstructIdentifier(lemma: "be", type: ConstructTypeEnum.vocab),
],
activityType: ActivityTypeEnum.multipleChoice,
langCode: event.messageDisplayLangCode,

View file

@ -1,7 +1,7 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
@ -123,6 +123,7 @@ class UserController extends BaseController {
: null;
final bool? autoPlay = migratedProfileInfo(MatrixProfile.autoPlayMessages);
final bool? itAutoPlay = migratedProfileInfo(MatrixProfile.itAutoPlay);
final bool? trial = migratedProfileInfo(MatrixProfile.activatedFreeTrial);
final bool? interactiveTranslator =
migratedProfileInfo(MatrixProfile.interactiveTranslator);
@ -142,6 +143,7 @@ class UserController extends BaseController {
await updateMatrixProfile(
dateOfBirth: dob,
autoPlayMessages: autoPlay,
itAutoPlay: itAutoPlay,
activatedFreeTrial: trial,
interactiveTranslator: interactiveTranslator,
interactiveGrammar: interactiveGrammar,
@ -223,6 +225,7 @@ class UserController extends BaseController {
Future<void> updateMatrixProfile({
String? dateOfBirth,
bool? autoPlayMessages,
bool? itAutoPlay,
bool? activatedFreeTrial,
bool? interactiveTranslator,
bool? interactiveGrammar,
@ -251,6 +254,12 @@ class UserController extends BaseController {
autoPlayMessages,
);
}
if (itAutoPlay != null) {
await _pangeaController.pStoreService.save(
MatrixProfile.itAutoPlay.title,
itAutoPlay,
);
}
if (activatedFreeTrial != null) {
await _pangeaController.pStoreService.save(
MatrixProfile.activatedFreeTrial.title,

View file

@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:http/http.dart' as http;
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/repo/word_repo.dart';
import '../models/word_data_model.dart';
import 'base_controller.dart';

View file

@ -0,0 +1,43 @@
// assistance state is, user has not typed a message, user has typed a message and IGC has not run,
// IGC is running, IGC has run and there are remaining steps (either IT or IGC), or all steps are done
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum AssistanceState {
noMessage,
notFetched,
fetching,
fetched,
complete,
}
extension AssistanceStateExtension on AssistanceState {
Color stateColor(context) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
case AssistanceState.fetching:
return Theme.of(context).colorScheme.primary;
case AssistanceState.fetched:
return PangeaColors.igcError;
case AssistanceState.complete:
return AppConfig.success;
}
}
String tooltip(L10n l10n) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
return l10n.runGrammarCorrection;
case AssistanceState.fetching:
return "";
case AssistanceState.fetched:
return l10n.grammarCorrectionFailed;
case AssistanceState.complete:
return l10n.grammarCorrectionComplete;
}
}
}

View file

@ -29,15 +29,4 @@ extension BarChartViewSelectionExtension on BarChartViewSelection {
return Icons.spellcheck_outlined;
}
}
String get route {
switch (this) {
case BarChartViewSelection.messages:
return 'messages';
// case BarChartViewSelection.vocab:
// return 'vocab';
case BarChartViewSelection.grammar:
return 'errors';
}
}
}

View file

@ -1,30 +1,30 @@
enum ConstructType {
enum ConstructTypeEnum {
grammar,
vocab,
}
extension ConstructExtension on ConstructType {
extension ConstructExtension on ConstructTypeEnum {
String get string {
switch (this) {
case ConstructType.grammar:
case ConstructTypeEnum.grammar:
return 'grammar';
case ConstructType.vocab:
case ConstructTypeEnum.vocab:
return 'vocab';
}
}
}
class ConstructTypeUtil {
static ConstructType fromString(String? string) {
static ConstructTypeEnum fromString(String? string) {
switch (string) {
case 'g':
case 'grammar':
return ConstructType.grammar;
return ConstructTypeEnum.grammar;
case 'v':
case 'vocab':
return ConstructType.vocab;
return ConstructTypeEnum.vocab;
default:
return ConstructType.vocab;
return ConstructTypeEnum.vocab;
}
}
}

View file

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
enum ConstructUseTypeEnum {
/// produced in chat by user, igc was run, and we've judged it to be a correct use
wa,
/// produced in chat by user, igc was run, and we've judged it to be a incorrect use
/// Note: if the IGC match is ignored, this is not counted as an incorrect use
ga,
/// produced in chat by user and igc was not run
unk,
/// selected correctly in IT flow
corIt,
/// encountered as IT distractor and correctly ignored it
ignIt,
/// encountered as it distractor and selected it
incIt,
/// encountered in igc match and ignored match
ignIGC,
/// selected correctly in IGC flow
corIGC,
/// encountered as distractor in IGC flow and selected it
incIGC,
/// selected correctly in practice activity flow
corPA,
/// was target construct in practice activity but user did not select correctly
incPA,
}
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
String get string {
switch (this) {
case ConstructUseTypeEnum.ga:
return 'ga';
case ConstructUseTypeEnum.wa:
return 'wa';
case ConstructUseTypeEnum.corIt:
return 'corIt';
case ConstructUseTypeEnum.incIt:
return 'incIt';
case ConstructUseTypeEnum.ignIt:
return 'ignIt';
case ConstructUseTypeEnum.ignIGC:
return 'ignIGC';
case ConstructUseTypeEnum.corIGC:
return 'corIGC';
case ConstructUseTypeEnum.incIGC:
return 'incIGC';
case ConstructUseTypeEnum.unk:
return 'unk';
case ConstructUseTypeEnum.corPA:
return 'corPA';
case ConstructUseTypeEnum.incPA:
return 'incPA';
}
}
IconData get icon {
switch (this) {
case ConstructUseTypeEnum.ga:
return Icons.check;
case ConstructUseTypeEnum.wa:
return Icons.thumb_up_sharp;
case ConstructUseTypeEnum.corIt:
return Icons.check;
case ConstructUseTypeEnum.incIt:
return Icons.close;
case ConstructUseTypeEnum.ignIt:
return Icons.close;
case ConstructUseTypeEnum.ignIGC:
return Icons.close;
case ConstructUseTypeEnum.corIGC:
return Icons.check;
case ConstructUseTypeEnum.incIGC:
return Icons.close;
case ConstructUseTypeEnum.corPA:
return Icons.check;
case ConstructUseTypeEnum.incPA:
return Icons.close;
case ConstructUseTypeEnum.unk:
return Icons.help;
}
}
}

View file

@ -0,0 +1,45 @@
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum InstructionsEnum {
itInstructions,
clickMessage,
blurMeansTranslate,
tooltipInstructions,
speechToText,
}
extension Copy on InstructionsEnum {
String title(BuildContext context) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsTitle;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageTitle;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateTitle;
case InstructionsEnum.tooltipInstructions:
return L10n.of(context)!.tooltipInstructionsTitle;
case InstructionsEnum.speechToText:
return L10n.of(context)!.hintTitle;
}
}
String body(BuildContext context) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsBody;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageBody;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateBody;
case InstructionsEnum.speechToText:
return L10n.of(context)!.speechToTextBody;
case InstructionsEnum.tooltipInstructions:
return PlatformInfos.isMobile
? L10n.of(context)!.tooltipInstructionsMobileBody
: L10n.of(context)!.tooltipInstructionsBrowserBody;
}
}
}

View file

@ -1,5 +1,5 @@
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum SpanDataTypeEnum {
@ -32,7 +32,10 @@ extension SpanDataTypeEnumExt on SpanDataTypeEnum {
case SpanDataTypeEnum.correction:
return L10n.of(context)!.correctionDefaultPrompt;
case SpanDataTypeEnum.itStart:
return L10n.of(context)!.needsItMessage;
return L10n.of(context)!.needsItMessage(
MatrixState.pangeaController.languageController.userL2?.displayName ??
L10n.of(context)!.targetLanguage,
);
}
}
}

View file

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../models/choreo_record.dart';
import '../utils/bot_style.dart';
enum UseType { wa, ta, ga, un }
@ -93,17 +91,3 @@ extension UseTypeMethods on UseType {
}
}
}
UseType useTypeCalculator(
ChoreoRecord? choreoRecord,
) {
if (choreoRecord == null) {
return UseType.un;
} else if (choreoRecord.includedIT) {
return UseType.ta;
} else if (choreoRecord.hasAcceptedMatches) {
return UseType.ga;
} else {
return UseType.wa;
}
}

View file

@ -51,7 +51,9 @@ extension PangeaClient on Client {
Future<List<Room>> get spacesImTeaching async => await _spacesImTeaching;
Future<List<Room>> get spacesImStudyingIn async => await _spacesImStudyingIn;
Future<List<Room>> get chatsImAStudentIn async => await _chatsImAStudentIn;
Future<List<Room>> get spaceImAStudentIn async => await _spacesImStudyingIn;
List<Room> get spacesImIn => _spacesImIn;

View file

@ -19,6 +19,18 @@ extension SpaceClientExtension on Client {
return spaces;
}
Future<List<Room>> get _chatsImAStudentIn async {
final List<String> nowteacherRoomIds = await teacherRoomIds;
return rooms
.where(
(r) =>
!r.isSpace &&
!r.isAnalyticsRoom &&
!nowteacherRoomIds.contains(r.id),
)
.toList();
}
Future<List<Room>> get _spacesImStudyingIn async {
final List<Room> joinedSpaces = rooms
.where(

View file

@ -28,7 +28,7 @@ extension PangeaEvent on Event {
return PangeaRepresentation.fromJson(json) as V;
case PangeaEventTypes.choreoRecord:
return ChoreoRecord.fromJson(json) as V;
case PangeaEventTypes.pangeaActivityRes:
case PangeaEventTypes.pangeaActivity:
return PracticeActivityModel.fromJson(json) as V;
case PangeaEventTypes.activityRecord:
return PracticeActivityRecordModel.fromJson(json) as V;

View file

@ -229,7 +229,6 @@ extension EventsRoomExtension on Room {
PangeaMessageTokens? tokensSent,
PangeaMessageTokens? tokensWritten,
ChoreoRecord? choreo,
UseType? useType,
}) {
// if (parseCommands) {
// return client.parseAndRunCommand(this, message,
@ -247,7 +246,6 @@ extension EventsRoomExtension on Room {
ModelKey.originalWritten: originalWritten?.toJson(),
ModelKey.tokensSent: tokensSent?.toJson(),
ModelKey.tokensWritten: tokensWritten?.toJson(),
ModelKey.useType: useType?.string,
};
if (parseMarkdown) {
final html = markdown(
@ -347,7 +345,7 @@ extension EventsRoomExtension on Room {
RecentMessageRecord(
eventId: event.eventId,
chatId: id,
useType: pMsgEvent.useType,
useType: pMsgEvent.msgUseType,
time: event.originServerTs,
),
);
@ -426,26 +424,6 @@ extension EventsRoomExtension on Room {
// }
// }
Future<List<PangeaMessageEvent>> myMessageEventsInChat({
DateTime? since,
}) async {
final List<Event> msgEvents = await getEventsBySender(
type: EventTypes.Message,
sender: client.userID!,
since: since,
);
final Timeline timeline = await getTimeline();
return msgEvents
.where((event) => (event.content['msgtype'] == MessageTypes.Text))
.map((event) {
return PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
}).toList();
}
// fetch event of a certain type by a certain sender
// since a certain time or up to a certain amount
Future<List<Event>> getEventsBySender({

View file

@ -4,14 +4,18 @@ import 'dart:developer';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
@ -30,7 +34,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import '../../../config/app_config.dart';
import '../../constants/pangea_event_types.dart';
import '../../enum/use_type.dart';
import '../../models/choreo_record.dart';
import '../../models/representation_content_model.dart';
import '../client_extension/client_extension.dart';
@ -129,6 +132,9 @@ extension PangeaRoom on Room {
Event? get pangeaRoomRulesStateEvent => _pangeaRoomRulesStateEvent;
Future<List<LanguageModel>> targetLanguages() async =>
await _targetLanguages();
// events
Future<bool> leaveIfFull() async => await _leaveIfFull();
@ -174,7 +180,6 @@ extension PangeaRoom on Room {
PangeaMessageTokens? tokensSent,
PangeaMessageTokens? tokensWritten,
ChoreoRecord? choreo,
UseType? useType,
}) =>
_pangeaSendTextEvent(
message,
@ -191,7 +196,6 @@ extension PangeaRoom on Room {
tokensSent: tokensSent,
tokensWritten: tokensWritten,
choreo: choreo,
useType: useType,
);
Future<String> updateStateEvent(Event stateEvent) =>

View file

@ -99,7 +99,7 @@ extension AnalyticsRoomExtension on Room {
return;
}
for (final Room space in (await client.spacesImStudyingIn)) {
for (final Room space in (await client.spaceImAStudentIn)) {
if (space.spaceChildren.any((sc) => sc.roomId == id)) continue;
await space.addAnalyticsRoomToSpace(this);
}
@ -175,7 +175,7 @@ extension AnalyticsRoomExtension on Room {
return;
}
for (final Room space in (await client.spacesImStudyingIn)) {
for (final Room space in (await client.spaceImAStudentIn)) {
await space.inviteSpaceTeachersToAnalyticsRoom(this);
}
}
@ -194,7 +194,7 @@ extension AnalyticsRoomExtension on Room {
final List<Event> events = await getEventsBySender(
type: type,
sender: userId,
count: 1,
count: 10,
);
if (events.isEmpty) return null;
final Event event = events.first;
@ -249,4 +249,31 @@ extension AnalyticsRoomExtension on Room {
return creationContent?.tryGet<String>(ModelKey.langCode) == langCode ||
creationContent?.tryGet<String>(ModelKey.oldLangCode) == langCode;
}
Future<String?> sendSummaryAnalyticsEvent(
List<RecentMessageRecord> records,
) async {
final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel(
messages: records,
);
final String? eventId = await sendEvent(
analyticsModel.toJson(),
type: PangeaEventTypes.summaryAnalytics,
);
return eventId;
}
Future<String?> sendConstructsEvent(
List<OneConstructUse> uses,
) async {
final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel(
uses: uses,
);
final String? eventId = await sendEvent(
constructsModel.toJson(),
type: PangeaEventTypes.construct,
);
return eventId;
}
}

View file

@ -92,6 +92,34 @@ extension SpaceRoomExtension on Room {
return null;
}
Future<List<LanguageModel>> _targetLanguages() async {
await requestParticipants();
final students = _students;
final Map<LanguageModel, int> langCounts = {};
final List<Room> allRooms = client.rooms;
for (final User student in students) {
for (final Room room in allRooms) {
if (!room.isAnalyticsRoomOfUser(student.id)) continue;
final String? langCode = room.madeForLang;
if (langCode == null ||
langCode.isEmpty ||
langCode == LanguageKeys.unknownLanguage) {
continue;
}
final LanguageModel lang = PangeaLanguage.byLangCode(langCode);
langCounts[lang] ??= 0;
langCounts[lang] = langCounts[lang]! + 1;
}
}
// get a list of language models, sorted
// by the number of students who are learning that language
return langCounts.entries.map((entry) => entry.key).toList()
..sort(
(a, b) => langCounts[b]!.compareTo(langCounts[a]!),
);
}
// DateTime? get _languageSettingsUpdatedAt {
// if (!isSpace) return null;
// return languageSettingsStateEvent?.originServerTs ?? creationTime;

View file

@ -2,14 +2,20 @@ import 'dart:convert';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/choreo_constants.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/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_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';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/lemma.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.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/speech_to_text_models.dart';
@ -22,7 +28,7 @@ import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../widgets/matrix.dart';
import '../constants/language_keys.dart';
import '../constants/language_constants.dart';
import '../constants/pangea_event_types.dart';
import '../enum/use_type.dart';
import '../utils/error_handler.dart';
@ -31,7 +37,6 @@ class PangeaMessageEvent {
late Event _event;
final Timeline timeline;
final bool ownMessage;
bool _isValidPangeaMessageEvent = true;
PangeaMessageEvent({
required Event event,
@ -39,7 +44,7 @@ class PangeaMessageEvent {
required this.ownMessage,
}) {
if (event.type != EventTypes.Message) {
_isValidPangeaMessageEvent = false;
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "${event.type} should not be used to make a PangeaMessageEvent",
);
@ -542,7 +547,18 @@ class PangeaMessageEvent {
originalWritten: false,
);
UseType get useType => useTypeCalculator(originalSent?.choreo);
UseType get msgUseType {
final ChoreoRecord? choreoRecord = originalSent?.choreo;
if (choreoRecord == null) {
return UseType.un;
} else if (choreoRecord.includedIT) {
return UseType.ta;
} else if (choreoRecord.hasAcceptedMatches) {
return UseType.ga;
} else {
return UseType.wa;
}
}
bool get showUseType =>
!ownMessage &&
@ -566,18 +582,8 @@ class PangeaMessageEvent {
/// If any activity is not complete, it returns true, indicating that the activity icon should be shown.
/// Otherwise, it returns false.
bool get hasUncompletedActivity {
if (l2Code == null) return false;
final List<PracticeActivityEvent> activities = practiceActivities(l2Code!);
if (activities.isEmpty) return false;
// for now, only show the button if the event has no completed activities
// TODO - revert this after adding logic to show next activity
for (final activity in activities) {
if (activity.isComplete) return false;
}
return true;
// if (activities.isEmpty) return false;
// return !activities.every((activity) => activity.isComplete);
if (practiceActivities.isEmpty) return false;
return practiceActivities.any((activity) => !(activity.isComplete));
}
String? get l2Code =>
@ -611,34 +617,36 @@ class PangeaMessageEvent {
return steps;
}
List<PracticeActivityEvent> get _practiceActivityEvents => _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.pangeaActivityRes,
)
.map(
(e) => PracticeActivityEvent(
timeline: timeline,
event: e,
),
)
.toList();
/// Returns a list of all [PracticeActivityEvent] objects
/// associated with this message event.
List<PracticeActivityEvent> get _practiceActivityEvents {
return _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.pangeaActivity,
)
.map(
(e) => PracticeActivityEvent(
timeline: timeline,
event: e,
),
)
.toList();
}
/// Returns a boolean value indicating whether there are any
/// activities associated with this message event for the user's active l2
bool get hasActivities {
try {
final String? l2code =
MatrixState.pangeaController.languageController.activeL2Code();
if (l2code == null) return false;
return practiceActivities(l2code).isNotEmpty;
return practiceActivities.isNotEmpty;
} catch (e, s) {
ErrorHandler.logError(e: e, s: s);
return false;
}
}
List<PracticeActivityEvent> practiceActivities(
/// Returns a list of [PracticeActivityEvent] objects for the given [langCode].
List<PracticeActivityEvent> practiceActivitiesByLangCode(
String langCode, {
bool debug = false,
}) {
@ -658,14 +666,170 @@ class PangeaMessageEvent {
}
}
// List<SpanData> get activities =>
//each match is turned into an activity that other students can access
//they're not told the answer but have to find it themselves
//the message has a blank piece which they fill in themselves
/// Returns a list of [PracticeActivityEvent] for the user's active l2.
List<PracticeActivityEvent> get practiceActivities =>
l2Code == null ? [] : practiceActivitiesByLangCode(l2Code!);
// replication of logic from message_content.dart
// bool get isHtml =>
// AppConfig.renderHtml && !_event.redacted && _event.isRichMessage;
/// all construct uses for the message, including vocab and grammar
List<OneConstructUse> get allConstructUses =>
[..._grammarConstructUses, ..._vocabUses, ..._itStepsToConstructUses];
/// Returns a list of [OneConstructUse] from itSteps for which the continuance
/// was selected or ignored. Correct selections are considered in the tokens
/// flow. Once all continuances have lemmas, we can do both correct and incorrect
/// in this flow. It actually doesn't do anything at all right now, because the
/// choregrapher is not returning lemmas for continuances. This is a TODO.
/// So currently only the lemmas can be gotten from the tokens for choices that
/// are actually in the final message.
List<OneConstructUse> get _itStepsToConstructUses {
final List<OneConstructUse> uses = [];
if (originalSent?.choreo == null) return uses;
for (final itStep in originalSent!.choreo!.itSteps) {
for (final continuance in itStep.continuances) {
// this seems to always be false for continuances right now
if (originalSent!.choreo!.finalMessage.contains(continuance.text)) {
continue;
}
if (continuance.wasClicked) {
//PTODO - account for end of flow score
if (continuance.level != ChoreoConstants.levelThresholdForGreen) {
uses.addAll(
_lemmasToVocabUses(
continuance.lemmas,
ConstructUseTypeEnum.incIt,
),
);
}
} else {
if (continuance.level != ChoreoConstants.levelThresholdForGreen) {
uses.addAll(
_lemmasToVocabUses(
continuance.lemmas,
ConstructUseTypeEnum.ignIt,
),
);
}
}
}
}
return uses;
}
/// get construct uses of type vocab for the message
List<OneConstructUse> get _vocabUses {
final List<OneConstructUse> uses = [];
// missing vital info so return
if (event.roomId == null || originalSent?.tokens == null) {
debugger(when: kDebugMode);
return uses;
}
// for each token, record whether selected in ga, ta, or wa
for (final token in originalSent!.tokens!) {
uses.addAll(_getVocabUseForToken(token));
}
return uses;
}
/// Returns a list of [OneConstructUse] objects for the given [token]
/// If there is no [originalSent] or [originalSent.choreo], the [token] is
/// considered to be a [ConstructUseTypeEnum.wa] as long as it matches the target language.
/// Later on, we may want to consider putting it in some category of like 'pending'
/// If the [token] is in the [originalSent.choreo.acceptedOrIgnoredMatch],
/// it is considered to be a [ConstructUseTypeEnum.ga].
/// If the [token] is in the [originalSent.choreo.acceptedOrIgnoredMatch.choices],
/// it is considered to be a [ConstructUseTypeEnum.corIt].
/// If the [token] is not included in any choreoStep, it is considered to be a [ConstructUseTypeEnum.wa].
List<OneConstructUse> _getVocabUseForToken(PangeaToken token) {
if (originalSent?.choreo == null) {
final bool inUserL2 = originalSent?.langCode == l2Code;
return _lemmasToVocabUses(
token.lemmas,
inUserL2 ? ConstructUseTypeEnum.wa : ConstructUseTypeEnum.unk,
);
}
for (final step in originalSent!.choreo!.choreoSteps) {
/// if 1) accepted match 2) token is in the replacement and 3) replacement
/// is in the overall step text, then token was a ga
if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted &&
(step.acceptedOrIgnoredMatch!.match.choices?.any(
(r) =>
r.value.contains(token.text.content) &&
step.text.contains(r.value),
) ??
false)) {
return _lemmasToVocabUses(token.lemmas, ConstructUseTypeEnum.ga);
}
if (step.itStep != null) {
final bool pickedThroughIT =
step.itStep!.chosenContinuance?.text.contains(token.text.content) ??
false;
if (pickedThroughIT) {
return _lemmasToVocabUses(token.lemmas, ConstructUseTypeEnum.corIt);
//PTODO - check if added via custom input in IT flow
}
}
}
return _lemmasToVocabUses(token.lemmas, ConstructUseTypeEnum.wa);
}
/// Convert a list of [lemmas] into a list of vocab uses
/// with the given [type]
List<OneConstructUse> _lemmasToVocabUses(
List<Lemma> lemmas,
ConstructUseTypeEnum type,
) {
final List<OneConstructUse> uses = [];
for (final lemma in lemmas) {
if (lemma.saveVocab) {
uses.add(
OneConstructUse(
useType: type,
chatId: event.roomId!,
timeStamp: event.originServerTs,
lemma: lemma.text,
form: lemma.form,
msgId: event.eventId,
constructType: ConstructTypeEnum.vocab,
),
);
}
}
return uses;
}
/// get construct uses of type grammar for the message
List<OneConstructUse> get _grammarConstructUses {
final List<OneConstructUse> uses = [];
if (originalSent?.choreo == null || event.roomId == null) return uses;
for (final step in originalSent!.choreo!.choreoSteps) {
if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted) {
final String name = step.acceptedOrIgnoredMatch!.match.rule?.id ??
step.acceptedOrIgnoredMatch!.match.shortMessage ??
step.acceptedOrIgnoredMatch!.match.type.typeName.name;
uses.add(
OneConstructUse(
useType: ConstructUseTypeEnum.ga,
chatId: event.roomId!,
timeStamp: event.originServerTs,
lemma: name,
form: name,
msgId: event.eventId,
constructType: ConstructTypeEnum.grammar,
id: "${event.eventId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}",
),
);
}
}
return uses;
}
}
class URLFinder {

View file

@ -12,7 +12,7 @@ import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../widgets/matrix.dart';
import '../constants/language_keys.dart';
import '../constants/language_constants.dart';
import '../constants/pangea_event_types.dart';
import '../models/choreo_record.dart';
import '../models/representation_content_model.dart';

View file

@ -1,24 +0,0 @@
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class PracticeActivityRecordEvent {
Event event;
PracticeActivityRecordModel? _content;
PracticeActivityRecordEvent({required this.event}) {
if (event.type != PangeaEventTypes.activityRecord) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityRecordEvent",
);
}
}
PracticeActivityRecordModel? get record {
_content ??= event.getPangeaContent<PracticeActivityRecordModel>();
return _content!;
}
}

View file

@ -1,7 +1,7 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
@ -27,7 +27,7 @@ class PracticeActivityEvent {
_content = content;
}
}
if (event.type != PangeaEventTypes.pangeaActivityRes) {
if (event.type != PangeaEventTypes.pangeaActivity) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityEvent",
);
@ -39,7 +39,7 @@ class PracticeActivityEvent {
return _content!;
}
//in aggregatedEvents for the event, find all practiceActivityRecordEvents whose sender matches the client's userId
/// All completion records assosiated with this activity
List<PracticeActivityRecordEvent> get allRecords {
if (timeline == null) {
debugger(when: kDebugMode);
@ -54,14 +54,26 @@ class PracticeActivityEvent {
.toList();
}
List<PracticeActivityRecordEvent> get userRecords => allRecords
.where(
(recordEvent) =>
recordEvent.event.senderId == recordEvent.event.room.client.userID,
)
.toList();
/// Completion record assosiated with this activity
/// for the logged in user, null if there is none
PracticeActivityRecordEvent? get userRecord {
final List<PracticeActivityRecordEvent> records = allRecords
.where(
(recordEvent) =>
recordEvent.event.senderId ==
recordEvent.event.room.client.userID,
)
.toList();
if (records.length > 1) {
debugPrint("There should only be one record per user per activity");
debugger(when: kDebugMode);
}
return records.firstOrNull;
}
String get parentMessageId => event.relationshipEventId!;
/// Checks if there are any user records in the list for this activity,
/// and, if so, then the activity is complete
bool get isComplete => userRecords.isNotEmpty;
bool get isComplete => userRecord != null;
}

View file

@ -0,0 +1,89 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/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/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class PracticeActivityRecordEvent {
Event event;
PracticeActivityRecordModel? _content;
PracticeActivityRecordEvent({required this.event}) {
if (event.type != PangeaEventTypes.activityRecord) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityRecordEvent",
);
}
}
PracticeActivityRecordModel get record {
_content ??= event.getPangeaContent<PracticeActivityRecordModel>();
return _content!;
}
Future<List<OneConstructUse>> uses(Timeline timeline) async {
try {
final String? parent = event.relationshipEventId;
if (parent == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null event.relationshipEventId",
data: event.toJson(),
);
return [];
}
final Event? practiceEvent =
await timeline.getEventById(event.relationshipEventId!);
if (practiceEvent == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null practiceActivityEvent with id $parent",
data: event.toJson(),
);
return [];
}
final PracticeActivityEvent practiceActivity = PracticeActivityEvent(
event: practiceEvent,
timeline: timeline,
);
final List<OneConstructUse> uses = [];
final List<ConstructIdentifier> constructIds =
practiceActivity.practiceActivity.tgtConstructs;
for (final construct in constructIds) {
uses.add(
OneConstructUse(
lemma: construct.lemma,
constructType: construct.type,
useType: record.useType,
//TODO - find form of construct within the message
//this is related to the feature of highlighting the target construct in the message
form: construct.lemma,
chatId: event.roomId ?? practiceEvent.roomId ?? timeline.room.id,
msgId: practiceActivity.parentMessageId,
timeStamp: event.originServerTs,
),
);
}
return uses;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event.toJson());
rethrow;
}
}
}

View file

@ -1,8 +1,6 @@
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
@ -28,32 +26,4 @@ abstract class AnalyticsEvent {
}
return contentCache!;
}
static List<String> analyticsEventTypes = [
PangeaEventTypes.summaryAnalytics,
PangeaEventTypes.construct,
];
static Future<String?> sendEvent(
Room analyticsRoom,
String type,
List<dynamic> analyticsContent,
) async {
String? eventId;
switch (type) {
case PangeaEventTypes.summaryAnalytics:
eventId = await SummaryAnalyticsEvent.sendSummaryAnalyticsEvent(
analyticsRoom,
analyticsContent.cast<RecentMessageRecord>(),
);
break;
case PangeaEventTypes.construct:
eventId = await ConstructAnalyticsEvent.sendConstructsEvent(
analyticsRoom,
analyticsContent.cast<OneConstructUse>(),
);
break;
}
return eventId;
}
}

View file

@ -12,7 +12,11 @@ abstract class AnalyticsModel {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsModel.formatSummaryContent(recentMsgs);
case PangeaEventTypes.construct:
return ConstructAnalyticsModel.formatConstructsContent(recentMsgs);
final List<OneConstructUse> uses = [];
for (final msg in recentMsgs) {
uses.addAll(msg.allConstructUses);
}
return uses;
}
return [];
}

View file

@ -18,19 +18,4 @@ class ConstructAnalyticsEvent extends AnalyticsEvent {
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
return contentCache as ConstructAnalyticsModel;
}
static Future<String?> sendConstructsEvent(
Room analyticsRoom,
List<OneConstructUse> uses,
) async {
final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel(
uses: uses,
);
final String? eventId = await analyticsRoom.sendEvent(
constructsModel.toJson(),
type: PangeaEventTypes.construct,
);
return eventId;
}
}

View file

@ -1,11 +1,9 @@
import 'dart:developer';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/analytics_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../../enum/construct_type_enum.dart';
@ -24,7 +22,7 @@ class ConstructAnalyticsModel extends AnalyticsModel {
if (json[_usesKey] is List) {
// This is the new format
uses.addAll(
json[_usesKey]
(json[_usesKey] as List)
.map((use) => OneConstructUse.fromJson(use))
.cast<OneConstructUse>()
.toList(),
@ -39,13 +37,13 @@ class ConstructAnalyticsModel extends AnalyticsModel {
final lemmaUses = useValue[_usesKey];
for (final useData in lemmaUses) {
final use = OneConstructUse(
useType: ConstructUseType.ga,
useType: ConstructUseTypeEnum.ga,
chatId: useData["chatId"],
timeStamp: DateTime.parse(useData["timeStamp"]),
lemma: lemma,
form: useData["form"],
msgId: useData["msgId"],
constructType: ConstructType.grammar,
constructType: ConstructTypeEnum.grammar,
);
uses.add(use);
}
@ -70,122 +68,13 @@ class ConstructAnalyticsModel extends AnalyticsModel {
_usesKey: uses.map((use) => use.toJson()).toList(),
};
}
static List<OneConstructUse> formatConstructsContent(
List<PangeaMessageEvent> recentMsgs,
) {
final List<PangeaMessageEvent> filtered = List.from(recentMsgs);
final List<OneConstructUse> uses = [];
for (final msg in filtered) {
if (msg.originalSent?.choreo == null) continue;
uses.addAll(
msg.originalSent!.choreo!.toGrammarConstructUse(
msg.eventId,
msg.room.id,
msg.originServerTs,
),
);
final List<PangeaToken>? tokens = msg.originalSent?.tokens;
if (tokens == null) continue;
uses.addAll(
msg.originalSent!.choreo!.toVocabUse(
tokens,
msg.room.id,
msg.eventId,
msg.originServerTs,
),
);
}
return uses;
}
}
enum ConstructUseType {
/// produced in chat by user, igc was run, and we've judged it to be a correct use
wa,
/// produced in chat by user, igc was run, and we've judged it to be a incorrect use
/// Note: if the IGC match is ignored, this is not counted as an incorrect use
ga,
/// produced in chat by user and igc was not run
unk,
/// selected correctly in IT flow
corIt,
/// encountered as IT distractor and correctly ignored it
ignIt,
/// encountered as it distractor and selected it
incIt,
/// encountered in igc match and ignored match
ignIGC,
/// selected correctly in IGC flow
corIGC,
/// encountered as distractor in IGC flow and selected it
incIGC,
}
extension on ConstructUseType {
String get string {
switch (this) {
case ConstructUseType.ga:
return 'ga';
case ConstructUseType.wa:
return 'wa';
case ConstructUseType.corIt:
return 'corIt';
case ConstructUseType.incIt:
return 'incIt';
case ConstructUseType.ignIt:
return 'ignIt';
case ConstructUseType.ignIGC:
return 'ignIGC';
case ConstructUseType.corIGC:
return 'corIGC';
case ConstructUseType.incIGC:
return 'incIGC';
case ConstructUseType.unk:
return 'unk';
}
}
IconData get icon {
switch (this) {
case ConstructUseType.ga:
return Icons.check;
case ConstructUseType.wa:
return Icons.thumb_up_sharp;
case ConstructUseType.corIt:
return Icons.check;
case ConstructUseType.incIt:
return Icons.close;
case ConstructUseType.ignIt:
return Icons.close;
case ConstructUseType.ignIGC:
return Icons.close;
case ConstructUseType.corIGC:
return Icons.check;
case ConstructUseType.incIGC:
return Icons.close;
case ConstructUseType.unk:
return Icons.help;
}
}
}
class OneConstructUse {
String? lemma;
ConstructType? constructType;
ConstructTypeEnum? constructType;
String? form;
ConstructUseType useType;
ConstructUseTypeEnum useType;
String chatId;
String? msgId;
DateTime timeStamp;
@ -204,7 +93,7 @@ class OneConstructUse {
factory OneConstructUse.fromJson(Map<String, dynamic> json) {
return OneConstructUse(
useType: ConstructUseType.values
useType: ConstructUseTypeEnum.values
.firstWhere((e) => e.string == json['useType']),
chatId: json['chatId'],
timeStamp: DateTime.parse(json['timeStamp']),
@ -248,7 +137,7 @@ class OneConstructUse {
class ConstructUses {
final List<OneConstructUse> uses;
final ConstructType constructType;
final ConstructTypeEnum constructType;
final String lemma;
ConstructUses({

View file

@ -18,18 +18,4 @@ class SummaryAnalyticsEvent extends AnalyticsEvent {
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
return contentCache as SummaryAnalyticsModel;
}
static Future<String?> sendSummaryAnalyticsEvent(
Room analyticsRoom,
List<RecentMessageRecord> records,
) async {
final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel(
messages: records,
);
final String? eventId = await analyticsRoom.sendEvent(
analyticsModel.toJson(),
type: PangeaEventTypes.summaryAnalytics,
);
return eventId;
}
}

View file

@ -50,7 +50,7 @@ class SummaryAnalyticsModel extends AnalyticsModel {
(msg) => RecentMessageRecord(
eventId: msg.eventId,
chatId: msg.room.id,
useType: msg.useType,
useType: msg.msgUseType,
time: msg.originServerTs,
),
)

View file

@ -13,44 +13,66 @@ class BotOptionsModel {
List<String> keywords;
bool safetyModeration;
String mode;
String? custom;
String? discussionTopic;
String? discussionKeywords;
bool? discussionTriggerScheduleEnabled;
int? discussionTriggerScheduleHourInterval;
bool? discussionTriggerReactionEnabled;
String? discussionTriggerReactionKey;
String? customSystemPrompt;
bool? customTriggerReactionEnabled;
String? customTriggerReactionKey;
BotOptionsModel({
////////////////////////////////////////////////////////////////////////////
// General Bot Options
////////////////////////////////////////////////////////////////////////////
this.languageLevel,
this.topic = "General Conversation",
this.keywords = const [],
this.safetyModeration = true,
this.mode = "discussion",
this.custom = "",
////////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
////////////////////////////////////////////////////////////////////////////
this.discussionTopic,
this.discussionKeywords,
this.discussionTriggerScheduleEnabled,
this.discussionTriggerScheduleHourInterval,
this.discussionTriggerReactionEnabled = true,
this.discussionTriggerReactionKey,
this.discussionTriggerReactionKey = "",
////////////////////////////////////////////////////////////////////////////
// Custom Mode Options
////////////////////////////////////////////////////////////////////////////
this.customSystemPrompt,
this.customTriggerReactionEnabled = true,
this.customTriggerReactionKey = "",
});
factory BotOptionsModel.fromJson(json) {
return BotOptionsModel(
//////////////////////////////////////////////////////////////////////////
// General Bot Options
//////////////////////////////////////////////////////////////////////////
languageLevel: json[ModelKey.languageLevel],
safetyModeration: json[ModelKey.safetyModeration] ?? true,
mode: json[ModelKey.mode] ?? "discussion",
custom: json[ModelKey.custom],
//////////////////////////////////////////////////////////////////////////
// Discussion Mode Options
//////////////////////////////////////////////////////////////////////////
discussionTopic: json[ModelKey.discussionTopic],
discussionKeywords: json[ModelKey.discussionKeywords],
discussionTriggerScheduleEnabled:
json[ModelKey.discussionTriggerScheduleEnabled],
discussionTriggerScheduleHourInterval:
json[ModelKey.discussionTriggerScheduleHourInterval],
discussionTriggerReactionEnabled:
json[ModelKey.discussionTriggerReactionEnabled],
discussionTriggerReactionKey: json[ModelKey.discussionTriggerReactionKey],
json[ModelKey.discussionTriggerReactionEnabled] ?? true,
discussionTriggerReactionKey:
json[ModelKey.discussionTriggerReactionKey] ?? "",
//////////////////////////////////////////////////////////////////////////
// Custom Mode Options
//////////////////////////////////////////////////////////////////////////
customSystemPrompt: json[ModelKey.customSystemPrompt],
customTriggerReactionEnabled:
json[ModelKey.customTriggerReactionEnabled] ?? true,
customTriggerReactionKey: json[ModelKey.customTriggerReactionKey] ?? "",
);
}
@ -61,17 +83,16 @@ class BotOptionsModel {
data[ModelKey.languageLevel] = languageLevel;
data[ModelKey.safetyModeration] = safetyModeration;
data[ModelKey.mode] = mode;
data[ModelKey.custom] = custom;
data[ModelKey.discussionTopic] = discussionTopic;
data[ModelKey.discussionKeywords] = discussionKeywords;
data[ModelKey.discussionTriggerScheduleEnabled] =
discussionTriggerScheduleEnabled;
data[ModelKey.discussionTriggerScheduleHourInterval] =
discussionTriggerScheduleHourInterval;
data[ModelKey.discussionTriggerReactionEnabled] =
discussionTriggerReactionEnabled;
discussionTriggerReactionEnabled ?? true;
data[ModelKey.discussionTriggerReactionKey] =
discussionTriggerReactionKey;
discussionTriggerReactionKey ?? "";
data[ModelKey.customSystemPrompt] = customSystemPrompt;
data[ModelKey.customTriggerReactionEnabled] =
customTriggerReactionEnabled ?? true;
data[ModelKey.customTriggerReactionKey] = customTriggerReactionKey ?? "";
return data;
} catch (e, s) {
debugger(when: kDebugMode);
@ -92,27 +113,27 @@ class BotOptionsModel {
case ModelKey.mode:
mode = value;
break;
case ModelKey.custom:
custom = value;
break;
case ModelKey.discussionTopic:
discussionTopic = value;
break;
case ModelKey.discussionKeywords:
discussionKeywords = value;
break;
case ModelKey.discussionTriggerScheduleEnabled:
discussionTriggerScheduleEnabled = value;
break;
case ModelKey.discussionTriggerScheduleHourInterval:
discussionTriggerScheduleHourInterval = value;
break;
case ModelKey.discussionTriggerReactionEnabled:
discussionTriggerReactionEnabled = value;
break;
case ModelKey.discussionTriggerReactionKey:
discussionTriggerReactionKey = value;
break;
case ModelKey.customSystemPrompt:
customSystemPrompt = value;
break;
case ModelKey.customTriggerReactionEnabled:
customTriggerReactionEnabled = value;
break;
case ModelKey.customTriggerReactionKey:
customTriggerReactionKey = value;
break;
default:
throw Exception('Invalid key for bot options - $key');
}

View file

@ -1,13 +1,8 @@
import 'dart:convert';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import '../constants/choreo_constants.dart';
import '../enum/construct_type_enum.dart';
import 'it_step.dart';
import 'lemma.dart';
/// this class lives within a [PangeaIGCEvent]
/// it always has a [RepresentationEvent] parent
@ -111,135 +106,6 @@ class ChoreoRecord {
openMatches: [],
);
/// [tokens] is the final list of tokens that were sent
/// if no ga or ta,
/// make wa use for each and return
/// else
/// for each saveable vocab in the final message
/// if vocab is contained in an accepted replacement, make ga use
/// if vocab is contained in ta choice,
/// if selected as choice, corIt
/// if written as customInput, corIt? (account for score in this)
/// for each it step
/// for each continuance
/// if not within the final message, save ignIT/incIT
List<OneConstructUse> toVocabUse(
List<PangeaToken> tokens,
String chatId,
String msgId,
DateTime timestamp,
) {
final List<OneConstructUse> uses = [];
final DateTime now = DateTime.now();
List<OneConstructUse> lemmasToVocabUses(
List<Lemma> lemmas,
ConstructUseType type,
) {
final List<OneConstructUse> uses = [];
for (final lemma in lemmas) {
if (lemma.saveVocab) {
uses.add(
OneConstructUse(
useType: type,
chatId: chatId,
timeStamp: timestamp,
lemma: lemma.text,
form: lemma.form,
msgId: msgId,
constructType: ConstructType.vocab,
),
);
}
}
return uses;
}
List<OneConstructUse> getVocabUseForToken(PangeaToken token) {
for (final step in choreoSteps) {
/// if 1) accepted match 2) token is in the replacement and 3) replacement
/// is in the overall step text, then token was a ga
if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted &&
(step.acceptedOrIgnoredMatch!.match.choices?.any(
(r) =>
r.value.contains(token.text.content) &&
step.text.contains(r.value),
) ??
false)) {
return lemmasToVocabUses(token.lemmas, ConstructUseType.ga);
}
if (step.itStep != null) {
final bool pickedThroughIT = step.itStep!.chosenContinuance?.text
.contains(token.text.content) ??
false;
if (pickedThroughIT) {
return lemmasToVocabUses(token.lemmas, ConstructUseType.corIt);
//PTODO - check if added via custom input in IT flow
}
}
}
return lemmasToVocabUses(token.lemmas, ConstructUseType.wa);
}
/// for each token, record whether selected in ga, ta, or wa
for (final token in tokens) {
uses.addAll(getVocabUseForToken(token));
}
for (final itStep in itSteps) {
for (final continuance in itStep.continuances) {
// this seems to always be false for continuances right now
if (finalMessage.contains(continuance.text)) {
continue;
}
if (continuance.wasClicked) {
//PTODO - account for end of flow score
if (continuance.level != ChoreoConstants.levelThresholdForGreen) {
uses.addAll(
lemmasToVocabUses(continuance.lemmas, ConstructUseType.incIt),
);
}
} else {
if (continuance.level != ChoreoConstants.levelThresholdForGreen) {
uses.addAll(
lemmasToVocabUses(continuance.lemmas, ConstructUseType.ignIt),
);
}
}
}
}
return uses;
}
List<OneConstructUse> toGrammarConstructUse(
String msgId,
String chatId,
DateTime timestamp,
) {
final List<OneConstructUse> uses = [];
for (final step in choreoSteps) {
if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted) {
final String name = step.acceptedOrIgnoredMatch!.match.rule?.id ??
step.acceptedOrIgnoredMatch!.match.shortMessage ??
step.acceptedOrIgnoredMatch!.match.type.typeName.name;
uses.add(
OneConstructUse(
useType: ConstructUseType.ga,
chatId: chatId,
timeStamp: timestamp,
lemma: name,
form: name,
msgId: msgId,
constructType: ConstructType.grammar,
id: "${msgId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}",
),
);
}
}
return uses;
}
List<ITStep> get itSteps =>
choreoSteps.where((e) => e.itStep != null).map((e) => e.itStep!).toList();

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:developer';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@ -154,32 +155,37 @@ class VocabTotals {
void addVocabUseBasedOnUseType(List<OneConstructUse> uses) {
for (final use in uses) {
switch (use.useType) {
case ConstructUseType.ga:
case ConstructUseTypeEnum.ga:
ga++;
break;
case ConstructUseType.wa:
case ConstructUseTypeEnum.wa:
wa++;
break;
case ConstructUseType.corIt:
case ConstructUseTypeEnum.corIt:
corIt++;
break;
case ConstructUseType.incIt:
case ConstructUseTypeEnum.incIt:
incIt++;
break;
case ConstructUseType.ignIt:
case ConstructUseTypeEnum.ignIt:
ignIt++;
break;
//TODO - these shouldn't be counted as such
case ConstructUseType.ignIGC:
case ConstructUseTypeEnum.ignIGC:
ignIt++;
break;
case ConstructUseType.corIGC:
case ConstructUseTypeEnum.corIGC:
corIt++;
break;
case ConstructUseType.incIGC:
case ConstructUseTypeEnum.incIGC:
incIt++;
break;
case ConstructUseType.unk:
//TODO if we bring back Headwords then we need to add these
case ConstructUseTypeEnum.corPA:
break;
case ConstructUseTypeEnum.incPA:
break;
case ConstructUseTypeEnum.unk:
break;
}
}

View file

@ -1,5 +1,6 @@
import 'dart:developer';
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';
@ -13,12 +14,11 @@ import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../constants/model_keys.dart';
import 'language_detection_model.dart';
// import 'package:language_tool/language_tool.dart';
class IGCTextData {
List<LanguageDetection> detections;
LanguageDetectionResponse detections;
String originalInput;
String? fullTextCorrection;
List<PangeaToken> tokens;
@ -42,6 +42,18 @@ class IGCTextData {
});
factory IGCTextData.fromJson(Map<String, dynamic> json) {
// changing this to allow for use of the LanguageDetectionResponse methods
// TODO - change API after we're sure all clients are updated. not urgent.
final LanguageDetectionResponse detections =
json[_detectionsKey] is Iterable
? LanguageDetectionResponse.fromJson({
"detections": json[_detectionsKey],
"full_text": json["original_input"],
})
: LanguageDetectionResponse.fromJson(
json[_detectionsKey] as Map<String, dynamic>,
);
return IGCTextData(
tokens: (json[_tokensKey] as Iterable)
.map<PangeaToken>(
@ -59,12 +71,7 @@ class IGCTextData {
.toList()
.cast<PangeaMatch>()
: [],
detections: (json[_detectionsKey] as Iterable)
.map<LanguageDetection>(
(e) => LanguageDetection.fromJson(e as Map<String, dynamic>),
)
.toList()
.cast<LanguageDetection>(),
detections: detections,
originalInput: json["original_input"],
fullTextCorrection: json["full_text_correction"],
userL1: json[ModelKey.userL1],
@ -79,7 +86,7 @@ class IGCTextData {
static const String _detectionsKey = "detections";
Map<String, dynamic> toJson() => {
_detectionsKey: detections.map((e) => e.toJson()).toList(),
_detectionsKey: detections.toJson(),
"original_input": originalInput,
"full_text_correction": fullTextCorrection,
_tokensKey: tokens.map((e) => e.toJson()).toList(),
@ -90,6 +97,18 @@ class IGCTextData {
"enable_igc": enableIGC,
};
/// if we haven't run IGC or IT or there are no matches, we use the highest validated detection
/// from [LanguageDetectionResponse.highestValidatedDetection]
/// if we have run igc/it and there are no matches, we can relax the threshold
/// and use the highest confidence detection
String get detectedLanguage {
if (!(enableIGC && enableIT) || matches.isNotEmpty) {
return detections.highestValidatedDetection().langCode;
} else {
return detections.highestConfidenceDetection.langCode;
}
}
// reconstruct fullText based on accepted match
//update offsets in existing matches to reflect the change
//if existing matches overlap with the accepted one, remove them??

View file

@ -1,6 +1,6 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';

View file

@ -1,6 +1,13 @@
/// Represents a lemma object
class Lemma {
/// [text] ex "ir" - text of the lemma of the word
final String text;
/// [form] ex "vamos" - conjugated form of the lemma and as it appeared in some original text
final String form;
/// [saveVocab] true - whether to save the lemma to the user's vocabulary
/// vocab that are not saved: emails, urls, numbers, punctuation, etc.
final bool saveVocab;
Lemma({required this.text, required this.saveVocab, required this.form});

View file

@ -27,7 +27,7 @@ class MultipleChoice {
return MultipleChoice(
question: json['question'] as String,
choices: (json['choices'] as List).map((e) => e as String).toList(),
answer: json['answer'] as String,
answer: json['answer'] ?? json['correct_answer'] as String,
);
}

View file

@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart';
class ConstructIdentifier {
final String lemma;
final ConstructType type;
final ConstructTypeEnum type;
ConstructIdentifier({required this.lemma, required this.type});
@ -16,7 +16,7 @@ class ConstructIdentifier {
try {
return ConstructIdentifier(
lemma: json['lemma'] as String,
type: ConstructType.values.firstWhere(
type: ConstructTypeEnum.values.firstWhere(
(e) => e.string == json['type'],
),
);
@ -243,9 +243,11 @@ class PracticeActivityModel {
.toList(),
langCode: json['lang_code'] as String,
msgId: json['msg_id'] as String,
activityType: ActivityTypeEnum.values.firstWhere(
(e) => e.string == json['activity_type'],
),
activityType: json['activity_type'] == "multipleChoice"
? ActivityTypeEnum.multipleChoice
: ActivityTypeEnum.values.firstWhere(
(e) => e.string == json['activity_type'],
),
multipleChoice: json['multiple_choice'] != null
? MultipleChoice.fromJson(
json['multiple_choice'] as Map<String, dynamic>,

View file

@ -5,16 +5,18 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
class PracticeActivityRecordModel {
final String? question;
late List<ActivityResponse> responses;
late List<ActivityRecordResponse> responses;
PracticeActivityRecordModel({
required this.question,
List<ActivityResponse>? responses,
List<ActivityRecordResponse>? responses,
}) {
if (responses == null) {
this.responses = List<ActivityResponse>.empty(growable: true);
this.responses = List<ActivityRecordResponse>.empty(growable: true);
} else {
this.responses = responses;
}
@ -26,7 +28,9 @@ class PracticeActivityRecordModel {
return PracticeActivityRecordModel(
question: json['question'] as String,
responses: (json['responses'] as List)
.map((e) => ActivityResponse.fromJson(e as Map<String, dynamic>))
.map(
(e) => ActivityRecordResponse.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
}
@ -40,26 +44,34 @@ class PracticeActivityRecordModel {
/// get the latest response index according to the response timeStamp
/// sort the responses by timestamp and get the index of the last response
String? get latestResponse {
ActivityRecordResponse? get latestResponse {
if (responses.isEmpty) {
return null;
}
responses.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return responses[responses.length - 1].text;
return responses[responses.length - 1];
}
ConstructUseTypeEnum get useType => latestResponse?.score != null
? (latestResponse!.score > 0
? ConstructUseTypeEnum.corPA
: ConstructUseTypeEnum.incPA)
: ConstructUseTypeEnum.unk;
void addResponse({
String? text,
Uint8List? audioBytes,
Uint8List? imageBytes,
required double score,
}) {
try {
responses.add(
ActivityResponse(
ActivityRecordResponse(
text: text,
audioBytes: audioBytes,
imageBytes: imageBytes,
timestamp: DateTime.now(),
score: score,
),
);
} catch (e) {
@ -84,27 +96,33 @@ class PracticeActivityRecordModel {
int get hashCode => question.hashCode ^ responses.hashCode;
}
class ActivityResponse {
class ActivityRecordResponse {
// the user's response
// has nullable string, nullable audio bytes, nullable image bytes, and timestamp
final String? text;
final Uint8List? audioBytes;
final Uint8List? imageBytes;
final DateTime timestamp;
final double score;
ActivityResponse({
ActivityRecordResponse({
this.text,
this.audioBytes,
this.imageBytes,
required this.score,
required this.timestamp,
});
factory ActivityResponse.fromJson(Map<String, dynamic> json) {
return ActivityResponse(
factory ActivityRecordResponse.fromJson(Map<String, dynamic> json) {
return ActivityRecordResponse(
text: json['text'] as String?,
audioBytes: json['audio'] as Uint8List?,
imageBytes: json['image'] as Uint8List?,
timestamp: DateTime.parse(json['timestamp'] as String),
// this has a default of 1 to make this backwards compatible
// score was added later and is not present in all records
// currently saved to Matrix
score: json['score'] ?? 1.0,
);
}
@ -114,6 +132,7 @@ class ActivityResponse {
'audio': audioBytes,
'image': imageBytes,
'timestamp': timestamp.toIso8601String(),
'score': score,
};
}
@ -121,7 +140,7 @@ class ActivityResponse {
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActivityResponse &&
return other is ActivityRecordResponse &&
other.text == text &&
other.audioBytes == audioBytes &&
other.imageBytes == imageBytes &&

View file

@ -7,7 +7,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../constants/class_default_values.dart';
import '../constants/language_keys.dart';
import '../constants/language_constants.dart';
import '../constants/pangea_event_types.dart';
import 'language_model.dart';

View file

@ -4,12 +4,12 @@ import 'package:country_picker/country_picker.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
import 'package:fluffychat/pangea/utils/instructions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../constants/language_keys.dart';
import '../constants/language_constants.dart';
import 'language_model.dart';
PUserModel pUserModelFromJson(String str) =>
@ -54,6 +54,7 @@ class PUserModel {
enum MatrixProfile {
dateOfBirth,
autoPlayMessages,
itAutoPlay,
activatedFreeTrial,
interactiveTranslator,
interactiveGrammar,
@ -79,6 +80,8 @@ extension MatrixProfileExtension on MatrixProfile {
return ModelKey.userDateOfBirth;
case MatrixProfile.autoPlayMessages:
return PLocalKey.autoPlayMessages;
case MatrixProfile.itAutoPlay:
return PLocalKey.itAutoPlay;
case MatrixProfile.activatedFreeTrial:
return PLocalKey.activatedTrialKey;
case MatrixProfile.interactiveTranslator:

View file

@ -16,7 +16,6 @@ class AnalyticsLanguageButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<LanguageModel>(
icon: const Icon(Icons.language_outlined),
tooltip: L10n.of(context)!.changeAnalyticsLanguage,
initialValue: value,
onSelected: (LanguageModel? lang) {
@ -33,6 +32,19 @@ class AnalyticsLanguageButton extends StatelessWidget {
child: Text(lang.getDisplayName(context) ?? lang.langCode),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.getDisplayName(context) ?? value.langCode,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
icon: Icon(
Icons.language_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
);
}
}

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -141,9 +140,7 @@ class AnalyticsListTileState extends State<AnalyticsListTile> {
return;
}
if ((room?.isSpace ?? false) && widget.allowNavigateOnSelect) {
final String selectedView =
widget.controller!.widget.selectedView!.route;
context.go('/rooms/analytics/${room!.id}/$selectedView');
context.go('/rooms/analytics/${room!.id}');
return;
}
widget.onTap(widget.selected);

View file

@ -0,0 +1,49 @@
import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class AnalyticsViewButton extends StatelessWidget {
final BarChartViewSelection value;
final void Function(BarChartViewSelection) onChange;
const AnalyticsViewButton({
super.key,
required this.value,
required this.onChange,
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<BarChartViewSelection>(
tooltip: L10n.of(context)!.changeAnalyticsView,
initialValue: value,
onSelected: (BarChartViewSelection? view) {
if (view == null) {
debugPrint("when is view null?");
return;
}
onChange(view);
},
itemBuilder: (BuildContext context) => BarChartViewSelection.values
.map<PopupMenuEntry<BarChartViewSelection>>(
(BarChartViewSelection view) {
return PopupMenuItem<BarChartViewSelection>(
value: view,
child: Text(view.string(context)),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
icon: Icon(
value.icon,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
);
}
}

View file

@ -20,21 +20,25 @@ import '../../models/analytics/chart_analytics_model.dart';
class BaseAnalyticsPage extends StatefulWidget {
final String pageTitle;
final List<TabData> tabs;
final BarChartViewSelection? selectedView;
final BarChartViewSelection selectedView;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? alwaysSelected;
final StudentAnalyticsController? myAnalyticsController;
final List<LanguageModel> targetLanguages;
const BaseAnalyticsPage({
BaseAnalyticsPage({
super.key,
required this.pageTitle,
required this.tabs,
required this.alwaysSelected,
required this.defaultSelected,
this.selectedView,
required this.selectedView,
this.myAnalyticsController,
});
targetLanguages,
}) : targetLanguages = (targetLanguages?.isNotEmpty ?? false)
? targetLanguages
: MatrixState.pangeaController.pLanguageStore.targetOptions;
@override
State<BaseAnalyticsPage> createState() => BaseAnalyticsController();
@ -46,6 +50,7 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
String? currentLemma;
ChartAnalyticsModel? chartData;
StreamController refreshStream = StreamController.broadcast();
BarChartViewSelection currentView = BarChartViewSelection.messages;
bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id;
@ -59,6 +64,7 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
@override
void initState() {
super.initState();
currentView = widget.selectedView;
if (widget.defaultSelected.type == AnalyticsEntryType.student) {
runFirstRefresh();
}
@ -159,7 +165,13 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
}
Future<void> toggleSpaceLang(LanguageModel lang) async {
await pangeaController.analytics.setCurrentAnalyticsSpaceLang(lang);
await pangeaController.analytics.setCurrentAnalyticsLang(lang);
await setChartData();
refreshStream.add(false);
}
Future<void> toggleView(BarChartViewSelection view) async {
currentView = view;
await setChartData();
refreshStream.add(false);
}

View file

@ -5,6 +5,7 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_language_button.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_view_button.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/pages/analytics/construct_list.dart';
import 'package:fluffychat/pangea/pages/analytics/messages_bar_chart.dart';
@ -24,18 +25,14 @@ class BaseAnalyticsView extends StatelessWidget {
final BaseAnalyticsController controller;
Widget chartView(BuildContext context) {
if (controller.widget.selectedView == null) {
return const SizedBox();
}
switch (controller.widget.selectedView!) {
switch (controller.currentView) {
case BarChartViewSelection.messages:
return MessagesBarChart(
chartAnalytics: controller.chartData,
);
case BarChartViewSelection.grammar:
return ConstructList(
constructType: ConstructType.grammar,
constructType: ConstructTypeEnum.grammar,
defaultSelected: controller.widget.defaultSelected,
selected: controller.selected,
controller: controller,
@ -75,27 +72,13 @@ class BaseAnalyticsView extends StatelessWidget {
if (controller.activeSpace != null)
TextSpan(
text: controller.activeSpace!.getLocalizedDisplayname(),
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () {
if (controller.widget.selectedView == null) return;
String route =
"/rooms/${controller.widget.defaultSelected.type.route}";
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.space) {
route += "/${controller.widget.defaultSelected.id}";
}
context.go(route);
},
),
if (controller.widget.selectedView != null)
const TextSpan(
text: " > ",
),
if (controller.widget.selectedView != null)
TextSpan(
text: controller.widget.selectedView!.string(context),
),
const TextSpan(
text: " > ",
),
TextSpan(
text: controller.currentView.string(context),
),
],
),
overflow: TextOverflow.ellipsis,
@ -104,223 +87,156 @@ class BaseAnalyticsView extends StatelessWidget {
),
body: MaxWidthBody(
withScrolling: false,
child: controller.widget.selectedView != null
? Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.student)
IconButton(
icon: const Icon(Icons.refresh),
onPressed: controller.onRefresh,
tooltip: L10n.of(context)!.refresh,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
AnalyticsViewButton(
value: controller.currentView,
onChange: controller.toggleView,
),
AnalyticsLanguageButton(
value: controller
.pangeaController.analytics.currentAnalyticsLang,
onChange: (lang) => controller.toggleSpaceLang(lang),
languages: controller.widget.targetLanguages,
),
],
),
const SizedBox(
height: 10,
),
Expanded(
flex: 1,
child: chartView(context),
),
Expanded(
flex: 1,
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
tabs: [
...controller.widget.tabs.map(
(tab) => Tab(
icon: Icon(
tab.icon,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.space)
AnalyticsLanguageButton(
value: controller.pangeaController.analytics
.currentAnalyticsSpaceLang,
onChange: (lang) => controller.toggleSpaceLang(lang),
languages: controller
.pangeaController.pLanguageStore.targetOptions,
),
],
),
Expanded(
flex: 1,
child: chartView(context),
),
Expanded(
flex: 1,
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
tabs: [
...controller.widget.tabs.map(
(tab) => Tab(
icon: Icon(
tab.icon,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
],
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(
height: max(
controller.widget.tabs[0].items.length + 1,
controller.widget.tabs[1].items.length,
) *
72,
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...controller.widget.tabs[0].items.map(
(item) => AnalyticsListTile(
refreshStream: controller.refreshStream,
avatar: item.avatar,
defaultSelected:
controller.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
isSelected:
controller.isSelected(item.id),
onTap: (_) => controller.toggleSelection(
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
),
allowNavigateOnSelect: controller
.widget.tabs[0].allowNavigateOnSelect,
pangeaController:
controller.pangeaController,
controller: controller,
),
),
),
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.space)
AnalyticsListTile(
refreshStream: controller.refreshStream,
defaultSelected:
controller.widget.defaultSelected,
avatar: null,
selected: AnalyticsSelected(
controller.widget.defaultSelected.id,
AnalyticsEntryType.privateChats,
L10n.of(context)!.allPrivateChats,
),
allowNavigateOnSelect: false,
isSelected: controller.isSelected(
controller.widget.defaultSelected.id,
),
onTap: controller.toggleSelection,
pangeaController:
controller.pangeaController,
controller: controller,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: controller.widget.tabs[1].items
.map(
(item) => AnalyticsListTile(
refreshStream: controller.refreshStream,
avatar: item.avatar,
defaultSelected:
controller.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[1].type,
item.displayName,
),
isSelected:
controller.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller.widget
.tabs[1].allowNavigateOnSelect,
pangeaController:
controller.pangeaController,
controller: controller,
),
)
.toList(),
),
],
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(
height: max(
controller.widget.tabs[0].items.length +
1,
controller.widget.tabs[1].items.length,
) *
72,
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
...controller.widget.tabs[0].items.map(
(item) => AnalyticsListTile(
refreshStream:
controller.refreshStream,
avatar: item.avatar,
defaultSelected: controller
.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
isSelected:
controller.isSelected(item.id),
onTap: (_) =>
controller.toggleSelection(
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
),
allowNavigateOnSelect: controller
.widget
.tabs[0]
.allowNavigateOnSelect,
pangeaController:
controller.pangeaController,
controller: controller,
),
),
if (controller
.widget.defaultSelected.type ==
AnalyticsEntryType.space)
AnalyticsListTile(
refreshStream:
controller.refreshStream,
defaultSelected: controller
.widget.defaultSelected,
avatar: null,
selected: AnalyticsSelected(
controller
.widget.defaultSelected.id,
AnalyticsEntryType.privateChats,
L10n.of(context)!.allPrivateChats,
),
allowNavigateOnSelect: false,
isSelected: controller.isSelected(
controller
.widget.defaultSelected.id,
),
onTap: controller.toggleSelection,
pangeaController:
controller.pangeaController,
controller: controller,
),
],
),
Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: controller.widget.tabs[1].items
.map(
(item) => AnalyticsListTile(
refreshStream:
controller.refreshStream,
avatar: item.avatar,
defaultSelected: controller
.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[1].type,
item.displayName,
),
isSelected: controller
.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller
.widget
.tabs[1]
.allowNavigateOnSelect,
pangeaController:
controller.pangeaController,
controller: controller,
),
)
.toList(),
),
],
),
),
),
),
],
),
),
),
),
],
)
: Column(
children: [
const Divider(height: 1),
ListTile(
title: Text(L10n.of(context)!.grammarAnalytics),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
Theme.of(context).textTheme.bodyLarge!.color,
child: Icon(BarChartViewSelection.grammar.icon),
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
String route =
"/rooms/${controller.widget.defaultSelected.type.route}";
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.space) {
route += "/${controller.widget.defaultSelected.id}";
}
route += "/${BarChartViewSelection.grammar.route}";
context.go(route);
},
),
const Divider(height: 1),
ListTile(
title: Text(L10n.of(context)!.messageAnalytics),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
Theme.of(context).textTheme.bodyLarge!.color,
child: Icon(BarChartViewSelection.messages.icon),
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
String route =
"/rooms/${controller.widget.defaultSelected.type.route}";
if (controller.widget.defaultSelected.type ==
AnalyticsEntryType.space) {
route += "/${controller.widget.defaultSelected.id}";
}
route += "/${BarChartViewSelection.messages.route}";
context.go(route);
},
),
const Divider(height: 1),
],
],
),
),
),
],
),
),
);
}

View file

@ -19,7 +19,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
class ConstructList extends StatefulWidget {
final ConstructType constructType;
final ConstructTypeEnum constructType;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? selected;
final BaseAnalyticsController controller;
@ -94,7 +94,7 @@ class ConstructListView extends StatefulWidget {
}
class ConstructListViewState extends State<ConstructListView> {
final ConstructType constructType = ConstructType.grammar;
final ConstructTypeEnum constructType = ConstructTypeEnum.grammar;
final Map<String, Timeline> _timelinesCache = {};
final Map<String, PangeaMessageEvent> _msgEventCache = {};
final List<PangeaMessageEvent> _msgEvents = [];
@ -355,15 +355,17 @@ class ConstructMessagesDialog extends StatelessWidget {
final msgEventMatches = controller.getMessageEventMatches();
final noData = controller.constructs![controller.lemmaIndex].uses.length >
controller._msgEvents.length;
return AlertDialog(
title: Center(child: Text(controller.widget.controller.currentLemma!)),
content: SizedBox(
height: 350,
width: 500,
height: noData ? 90 : 250,
width: noData ? 200 : 400,
child: Column(
children: [
if (controller.constructs![controller.lemmaIndex].uses.length >
controller._msgEvents.length)
if (noData)
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
@ -398,8 +400,8 @@ class ConstructMessagesDialog extends StatelessWidget {
child: Text(
L10n.of(context)!.close.toUpperCase(),
style: TextStyle(
color:
Theme.of(context).textTheme.bodyMedium?.color?.withAlpha(150),
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart';
import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart';
@ -17,8 +18,8 @@ import '../../../utils/sync_status_util_v2.dart';
import 'space_analytics_view.dart';
class SpaceAnalyticsPage extends StatefulWidget {
final BarChartViewSelection? selectedView;
const SpaceAnalyticsPage({super.key, this.selectedView});
final BarChartViewSelection selectedView;
const SpaceAnalyticsPage({super.key, required this.selectedView});
@override
State<SpaceAnalyticsPage> createState() => SpaceAnalyticsV2Controller();
@ -33,6 +34,18 @@ class SpaceAnalyticsV2Controller extends State<SpaceAnalyticsPage> {
List<User> students = [];
String? get spaceId => GoRouterState.of(context).pathParameters['spaceid'];
Room? _spaceRoom;
List<LanguageModel> targetLanguages = [];
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
if (spaceRoom == null || (!(spaceRoom?.isSpace ?? false))) {
context.go('/rooms');
}
getChatAndStudents();
});
}
Room? get spaceRoom {
if (_spaceRoom == null || _spaceRoom!.id != spaceId) {
@ -44,23 +57,11 @@ class SpaceAnalyticsV2Controller extends State<SpaceAnalyticsPage> {
context.go('/rooms/analytics');
return null;
}
getChatAndStudents();
getChatAndStudents().then((_) => setTargetLanguages());
}
return _spaceRoom;
}
@override
void initState() {
super.initState();
debugPrint("init space analytics");
Future.delayed(Duration.zero, () async {
if (spaceRoom == null || (!(spaceRoom?.isSpace ?? false))) {
context.go('/rooms');
}
getChatAndStudents();
});
}
Future<void> getChatAndStudents() async {
try {
await spaceRoom?.postLoad();
@ -97,12 +98,12 @@ class SpaceAnalyticsV2Controller extends State<SpaceAnalyticsPage> {
}
}
// @override
// void dispose() {
// super.dispose();
// refreshTimer?.cancel();
// stateSub?.cancel();
// }
Future<void> setTargetLanguages() async {
// get a list of language models, sorted by the
// number of students who are learning that language
targetLanguages = await spaceRoom?.targetLanguages() ?? [];
setState(() {});
}
@override
Widget build(BuildContext context) {

View file

@ -59,6 +59,7 @@ class SpaceAnalyticsView extends StatelessWidget {
AnalyticsEntryType.space,
controller.spaceRoom?.name ?? "",
),
targetLanguages: controller.targetLanguages,
)
: const SizedBox();
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/pages/analytics/space_list/space_list_view.dart';
import 'package:flutter/material.dart';
@ -22,26 +23,58 @@ class AnalyticsSpaceList extends StatefulWidget {
class AnalyticsSpaceListController extends State<AnalyticsSpaceList> {
PangeaController pangeaController = MatrixState.pangeaController;
List<Room> spaces = [];
StreamSubscription? stateSub;
List<LanguageModel> targetLanguages = [];
@override
void initState() {
super.initState();
Matrix.of(context).client.spacesImTeaching.then((spaceList) {
spaceList = spaceList
.where(
(space) => !spaceList.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
),
)
.toList();
spaces = spaceList;
setState(() {});
});
setSpaceList().then((_) => setTargetLanguages());
// reload dropdowns when their values change in analytics page
stateSub = pangeaController.analytics.stateStream.listen(
(_) => setState(() {}),
);
}
@override
void dispose() {
stateSub?.cancel();
super.dispose();
}
StreamController refreshStream = StreamController.broadcast();
Future<void> setSpaceList() async {
final spaceList = await Matrix.of(context).client.spacesImTeaching;
spaces = spaceList
.where(
(space) => !spaceList.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
),
)
.toList();
setState(() {});
}
Future<void> setTargetLanguages() async {
if (spaces.isEmpty) return;
final Map<LanguageModel, int> langCounts = {};
for (final Room space in spaces) {
final List<LanguageModel> targetLangs = await space.targetLanguages();
for (final LanguageModel lang in targetLangs) {
langCounts[lang] ??= 0;
langCounts[lang] = langCounts[lang]! + 1;
}
}
targetLanguages = langCounts.entries.map((entry) => entry.key).toList()
..sort(
(a, b) => langCounts[b]!.compareTo(langCounts[a]!),
);
setState(() {});
}
void toggleTimeSpan(BuildContext context, TimeSpan timeSpan) {
pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan);
refreshStream.add(false);
@ -49,7 +82,7 @@ class AnalyticsSpaceListController extends State<AnalyticsSpaceList> {
}
Future<void> toggleSpaceLang(LanguageModel lang) async {
await pangeaController.analytics.setCurrentAnalyticsSpaceLang(lang);
await pangeaController.analytics.setCurrentAnalyticsLang(lang);
refreshStream.add(false);
setState(() {});
}

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_language_button.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart';
import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart';
@ -5,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import '../../../enum/time_span.dart';
import '../base_analytics.dart';
import 'space_list.dart';
@ -32,25 +32,29 @@ class AnalyticsSpaceListView extends StatelessWidget {
icon: const Icon(Icons.close_outlined),
onPressed: () => context.pop(),
),
actions: [
TimeSpanMenuButton(
value:
controller.pangeaController.analytics.currentAnalyticsTimeSpan,
onChange: (TimeSpan value) => controller.toggleTimeSpan(
context,
value,
),
),
AnalyticsLanguageButton(
value:
controller.pangeaController.analytics.currentAnalyticsSpaceLang,
onChange: (lang) => controller.toggleSpaceLang(lang),
languages: controller.pangeaController.pLanguageStore.targetOptions,
),
],
),
body: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TimeSpanMenuButton(
value: controller
.pangeaController.analytics.currentAnalyticsTimeSpan,
onChange: (TimeSpan value) => controller.toggleTimeSpan(
context,
value,
),
),
AnalyticsLanguageButton(
value:
controller.pangeaController.analytics.currentAnalyticsLang,
onChange: (lang) => controller.toggleSpaceLang(lang),
languages:
controller.pangeaController.pLanguageStore.targetOptions,
),
],
),
Flexible(
child: ListView.builder(
itemCount: controller.spaces.length,

View file

@ -1,7 +1,11 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/controllers/language_list_controller.dart';
import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -14,8 +18,8 @@ import '../base_analytics.dart';
import 'student_analytics_view.dart';
class StudentAnalyticsPage extends StatefulWidget {
final BarChartViewSelection? selectedView;
const StudentAnalyticsPage({super.key, this.selectedView});
final BarChartViewSelection selectedView;
const StudentAnalyticsPage({super.key, required this.selectedView});
@override
State<StudentAnalyticsPage> createState() => StudentAnalyticsController();
@ -24,49 +28,35 @@ class StudentAnalyticsPage extends StatefulWidget {
class StudentAnalyticsController extends State<StudentAnalyticsPage> {
final PangeaController _pangeaController = MatrixState.pangeaController;
AnalyticsSelected? selected;
StreamSubscription? stateSub;
@override
void initState() {
super.initState();
final listFutures = [
_pangeaController.myAnalytics.setStudentChats(),
_pangeaController.myAnalytics.setStudentSpaces(),
];
Future.wait(listFutures).then((_) => setState(() {}));
stateSub = _pangeaController.myAnalytics.stateStream.listen((_) {
setState(() {});
});
}
@override
void dispose() {
stateSub?.cancel();
super.dispose();
}
List<Room> _chats = [];
List<Room> get chats {
if (_pangeaController.myAnalytics.studentChats.isEmpty) {
_pangeaController.myAnalytics.setStudentChats().then((_) {
if (_pangeaController.myAnalytics.studentChats.isNotEmpty) {
setState(() {});
}
if (_chats.isEmpty) {
_pangeaController.matrixState.client.chatsImAStudentIn.then((result) {
setState(() => _chats = result);
});
}
return _pangeaController.myAnalytics.studentChats;
return _chats;
}
List<Room> _spaces = [];
List<Room> get spaces {
if (_pangeaController.myAnalytics.studentSpaces.isEmpty) {
_pangeaController.myAnalytics.setStudentSpaces().then((_) {
if (_pangeaController.myAnalytics.studentSpaces.isNotEmpty) {
setState(() {});
}
if (_spaces.isEmpty) {
_pangeaController.matrixState.client.spaceImAStudentIn.then((result) {
setState(() => _spaces = result);
});
}
return _pangeaController.myAnalytics.studentSpaces;
return _spaces;
}
String? get userId {
@ -75,6 +65,24 @@ class StudentAnalyticsController extends State<StudentAnalyticsPage> {
return id;
}
List<LanguageModel> get targetLanguages {
final LanguageModel? l2 =
_pangeaController.languageController.activeL2Model();
final List<LanguageModel> analyticsRoomLangs =
_pangeaController.matrixState.client.allMyAnalyticsRooms
.map((analyticsRoom) => analyticsRoom.madeForLang)
.where((langCode) => langCode != null)
.map((langCode) => PangeaLanguage.byLangCode(langCode!))
.where(
(langModel) => langModel.langCode != LanguageKeys.unknownLanguage,
)
.toList();
if (l2 != null) {
analyticsRoomLangs.add(l2);
}
return analyticsRoomLangs.toSet().toList();
}
@override
Widget build(BuildContext context) {
return PLoadingStatusV2(

View file

@ -59,6 +59,7 @@ class StudentAnalyticsView extends StatelessWidget {
AnalyticsEntryType.student,
L10n.of(context)!.allChatsAndClasses,
),
targetLanguages: controller.targetLanguages,
)
: const SizedBox();
}

View file

@ -15,7 +15,6 @@ class TimeSpanMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<TimeSpan>(
icon: const Icon(Icons.calendar_month_outlined),
tooltip: L10n.of(context)!.changeDateRange,
initialValue: value,
onSelected: (TimeSpan? timeSpan) {
@ -32,6 +31,19 @@ class TimeSpanMenuButton extends StatelessWidget {
child: Text(timeSpan.string(context)),
);
}).toList(),
child: TextButton.icon(
label: Text(
value.string(context),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
icon: Icon(
Icons.calendar_month_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: null,
),
);
}
}

View file

@ -17,6 +17,7 @@ class ClassDescriptionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final iconColor = Theme.of(context).textTheme.bodyLarge!.color;
final ScrollController scrollController = ScrollController();
return Column(
children: [
ListTile(
@ -26,14 +27,27 @@ class ClassDescriptionButton extends StatelessWidget {
foregroundColor: iconColor,
child: const Icon(Icons.topic_outlined),
),
subtitle: Text(
room.topic.isEmpty
? (room.isRoomAdmin
? (room.isSpace
? L10n.of(context)!.classDescriptionDesc
: L10n.of(context)!.chatTopicDesc)
: L10n.of(context)!.topicNotSet)
: room.topic,
subtitle: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 190,
),
child: Scrollbar(
controller: scrollController,
interactive: true,
child: SingleChildScrollView(
controller: scrollController,
primary: false,
child: Text(
room.topic.isEmpty
? (room.isRoomAdmin
? (room.isSpace
? L10n.of(context)!.classDescriptionDesc
: L10n.of(context)!.chatTopicDesc)
: L10n.of(context)!.topicNotSet)
: room.topic,
),
),
),
),
title: Text(
room.isSpace

View file

@ -61,6 +61,16 @@ class SettingsLearningView extends StatelessWidget {
pStoreKey: setting.toString(),
local: false,
),
PSettingsSwitchListTile.adaptive(
defaultValue: controller.pangeaController.pStoreService.read(
PLocalKey.itAutoPlay,
) ??
false,
title: L10n.of(context)!.interactiveTranslatorAutoPlaySliderHeader,
subtitle: L10n.of(context)!.interactiveTranslatorAutoPlayDesc,
pStoreKey: PLocalKey.itAutoPlay,
local: false,
),
PSettingsSwitchListTile.adaptive(
defaultValue: controller.pangeaController.pStoreService.read(
PLocalKey.autoPlayMessages,

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:fluffychat/pangea/config/environment.dart';
import 'package:fluffychat/pangea/controllers/language_detection_controller.dart';
import 'package:fluffychat/pangea/models/language_detection_model.dart';
import 'package:fluffychat/pangea/models/lemma.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
@ -39,7 +40,10 @@ class IgcRepo {
await Future.delayed(const Duration(seconds: 2));
final IGCTextData igcTextData = IGCTextData(
detections: [LanguageDetection(langCode: "en", confidence: 0.99)],
detections: LanguageDetectionResponse(
detections: [LanguageDetection(langCode: "en", confidence: 0.99)],
fullText: "This be a sample text",
),
tokens: [
PangeaToken(
text: PangeaTokenText(content: "This", offset: 0, length: 4),
@ -89,7 +93,6 @@ class IGCRequestBody {
String fullText;
String userL1;
String userL2;
bool tokensOnly;
bool enableIT;
bool enableIGC;
@ -99,7 +102,6 @@ class IGCRequestBody {
required this.userL2,
required this.enableIGC,
required this.enableIT,
this.tokensOnly = false,
});
Map<String, dynamic> toJson() => {
@ -108,6 +110,5 @@ class IGCRequestBody {
ModelKey.userL2: userL2,
"enable_it": enableIT,
"enable_igc": enableIGC,
"tokens_only": tokensOnly,
};
}

View file

@ -4,7 +4,7 @@ import 'package:sentry_flutter/sentry_flutter.dart';
class PangeaAnyState {
final Map<String, LayerLinkAndKey> _layerLinkAndKeys = {};
OverlayEntry? overlay;
List<OverlayEntry> entries = [];
dispose() {
closeOverlay();
@ -32,26 +32,32 @@ class PangeaAnyState {
_layerLinkAndKeys.remove(transformTargetId);
}
void openOverlay(OverlayEntry entry, BuildContext context) {
closeOverlay();
overlay = entry;
Overlay.of(context).insert(overlay!);
void openOverlay(
OverlayEntry entry,
BuildContext context, {
bool closePrevOverlay = true,
}) {
if (closePrevOverlay) {
closeOverlay();
}
entries.add(entry);
Overlay.of(context).insert(entry);
}
void closeOverlay() {
if (overlay != null) {
if (entries.isNotEmpty) {
try {
overlay?.remove();
entries.last.remove();
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"overlay": overlay,
"overlay": entries.last,
},
);
}
overlay = null;
entries.removeLast();
}
}

View file

@ -4,7 +4,6 @@ import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:flutter/widgets.dart';
import '../../config/firebase_options.dart';
import '../enum/use_type.dart';
// PageRoute import
@ -90,13 +89,12 @@ class GoogleAnalytics {
logEvent('join_group', parameters: {'group_id': classCode});
}
static sendMessage(String chatRoomId, String classCode, UseType useType) {
static sendMessage(String chatRoomId, String classCode) {
logEvent(
'sent_message',
parameters: {
"chat_id": chatRoomId,
'group_id': classCode,
"message_type": useType.toString(),
},
);
}

View file

@ -1,5 +1,5 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/constants/language_constants.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';

View file

@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
class InlineTooltip extends StatelessWidget {
final String body;
final VoidCallback onClose;
const InlineTooltip({
super.key,
required this.body,
required this.onClose,
});
@override
Widget build(BuildContext context) {
return Badge(
offset: const Offset(0, -7),
backgroundColor: Colors.transparent,
label: CircleAvatar(
radius: 10,
child: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(
Icons.close_outlined,
size: 15,
),
onPressed: onClose,
),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme.of(context).colorScheme.primary.withAlpha(20),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: RichText(
textAlign: TextAlign.justify,
text: TextSpan(
children: [
const WidgetSpan(
child: Icon(
Icons.lightbulb,
size: 16,
),
),
const WidgetSpan(
child: SizedBox(width: 5),
),
TextSpan(
text: body,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
),
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/utils/inline_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -14,17 +15,29 @@ import 'overlay.dart';
class InstructionsController {
late PangeaController _pangeaController;
// We have these three methods to make sure that the instructions are not shown too much
/// Instruction popup was closed by the user
final Map<InstructionsEnum, bool> _instructionsClosed = {};
/// Instruction popup has already been shown this session
final Map<InstructionsEnum, bool> _instructionsShown = {};
/// Returns true if the user requested this popup not be shown again
bool? toggledOff(InstructionsEnum key) =>
_pangeaController.pStoreService.read(key.toString());
InstructionsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
/// Returns true if the instructions were closed
/// or turned off by the user via the toggle switch
bool wereInstructionsTurnedOff(InstructionsEnum key) =>
_pangeaController.pStoreService.read(key.toString()) ??
_instructionsClosed[key] ??
false;
toggledOff(key) ?? _instructionsClosed[key] ?? false;
void turnOffInstruction(InstructionsEnum key) =>
_instructionsClosed[key] = true;
Future<void> updateEnableInstructions(
InstructionsEnum key,
@ -35,12 +48,19 @@ class InstructionsController {
value,
);
Future<void> show(
/// Instruction Card gives users tips on
/// how to use Pangea Chat's features
Future<void> showInstructionsPopup(
BuildContext context,
InstructionsEnum key,
String transformTargetKey, [
bool showToggle = true,
]) async {
if (_instructionsShown[key] ?? false) {
return;
}
_instructionsShown[key] = true;
if (wereInstructionsTurnedOff(key)) {
return;
}
@ -51,9 +71,6 @@ class InstructionsController {
);
return;
}
if (_instructionsShown[key] ?? false) {
return;
}
final bool userLangsSet =
await _pangeaController.userController.areUserLanguagesSet;
@ -61,8 +78,6 @@ class InstructionsController {
return;
}
_instructionsShown[key] = true;
final botStyle = BotStyle.text(context);
Future.delayed(
const Duration(seconds: 1),
@ -74,7 +89,7 @@ class InstructionsController {
children: [
CardHeader(
text: key.title(context),
botExpression: BotExpression.surprised,
botExpression: BotExpression.idle,
onClose: () => {_instructionsClosed[key] = true},
),
const SizedBox(height: 10.0),
@ -94,48 +109,39 @@ class InstructionsController {
),
cardSize: const Size(300.0, 300.0),
transformTargetId: transformTargetKey,
closePrevOverlay: false,
),
);
}
/// Returns a widget that will be added to existing widget
/// which displays hint text defined in the enum extension
Widget getInstructionInlineTooltip(
BuildContext context,
InstructionsEnum key,
VoidCallback onClose,
) {
if (wereInstructionsTurnedOff(key)) {
return const SizedBox();
}
if (L10n.of(context) == null) {
ErrorHandler.logError(
m: "null context in ITBotButton.showCard",
s: StackTrace.current,
);
return const SizedBox();
}
return InlineTooltip(
body: InstructionsEnum.speechToText.body(context),
onClose: onClose,
);
}
}
enum InstructionsEnum {
itInstructions,
clickMessage,
blurMeansTranslate,
tooltipInstructions,
}
extension Copy on InstructionsEnum {
String title(BuildContext context) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsTitle;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageTitle;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateTitle;
case InstructionsEnum.tooltipInstructions:
return L10n.of(context)!.tooltipInstructionsTitle;
}
}
String body(BuildContext context) {
switch (this) {
case InstructionsEnum.itInstructions:
return L10n.of(context)!.itInstructionsBody;
case InstructionsEnum.clickMessage:
return L10n.of(context)!.clickMessageBody;
case InstructionsEnum.blurMeansTranslate:
return L10n.of(context)!.blurMeansTranslateBody;
case InstructionsEnum.tooltipInstructions:
return PlatformInfos.isMobile
? L10n.of(context)!.tooltipInstructionsMobileBody
: L10n.of(context)!.tooltipInstructionsBrowserBody;
}
}
}
/// User can toggle on to prevent Instruction Card
/// from appearing in future sessions
class InstructionsToggle extends StatefulWidget {
const InstructionsToggle({
super.key,

View file

@ -1,13 +1,13 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/enum/span_data_type.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../constants/match_rule_ids.dart';
import '../models/pangea_match_model.dart';
@ -96,7 +96,11 @@ class MatchCopy {
switch (afterColon) {
case MatchRuleIds.interactiveTranslation:
title = l10n.needsItShortMessage;
description = l10n.needsItMessage;
description = l10n.needsItMessage(
MatrixState
.pangeaController.languageController.userL2?.displayName ??
"target language",
);
break;
case MatchRuleIds.tokenNeedsTranslation:
title = l10n.tokenTranslationTitle;

View file

@ -25,9 +25,12 @@ class OverlayUtil {
Color? backgroundColor,
Alignment? targetAnchor,
Alignment? followerAnchor,
bool closePrevOverlay = true,
}) {
try {
MatrixState.pAnyState.closeOverlay();
if (closePrevOverlay) {
MatrixState.pAnyState.closeOverlay();
}
final LayerLinkAndKey layerLinkAndKey =
MatrixState.pAnyState.layerLinkAndKey(transformTargetId);
@ -58,7 +61,8 @@ class OverlayUtil {
),
);
MatrixState.pAnyState.openOverlay(entry, context);
MatrixState.pAnyState
.openOverlay(entry, context, closePrevOverlay: closePrevOverlay);
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: stack);
@ -72,6 +76,7 @@ class OverlayUtil {
required String transformTargetId,
backDropToDismiss = true,
Color? borderColor,
bool closePrevOverlay = true,
}) {
try {
final LayerLinkAndKey layerLinkAndKey =
@ -105,6 +110,7 @@ class OverlayUtil {
offset: cardOffset,
backDropToDismiss: backDropToDismiss,
borderColor: borderColor,
closePrevOverlay: closePrevOverlay,
);
} catch (err, stack) {
debugger(when: kDebugMode);
@ -180,7 +186,7 @@ class OverlayUtil {
return Offset(dx, dy);
}
static bool get isOverlayOpen => MatrixState.pAnyState.overlay != null;
static bool get isOverlayOpen => MatrixState.pAnyState.entries.isNotEmpty;
}
class TransparentBackdrop extends StatelessWidget {

View file

@ -1,9 +1,9 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/instructions_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/speech_to_text_models.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/instructions.dart';
import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/widgets/common/icon_number_widget.dart';
import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
@ -65,6 +65,13 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
}
}
void closeHint() {
MatrixState.pangeaController.instructions.turnOffInstruction(
InstructionsEnum.speechToText,
);
setState(() {});
}
TextSpan _buildTranscriptText(BuildContext context) {
final Transcript transcript = speechToTextResponse!.transcript;
final List<InlineSpan> spans = [];
@ -172,7 +179,8 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
number:
"${selectedToken?.confidence ?? speechToTextResponse!.transcript.confidence}%",
toolTip: L10n.of(context)!.accuracy,
onPressed: () => MatrixState.pangeaController.instructions.show(
onPressed: () => MatrixState.pangeaController.instructions
.showInstructionsPopup(
context,
InstructionsEnum.tooltipInstructions,
widget.messageEvent.eventId,
@ -184,7 +192,8 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
number:
wordsPerMinuteString != null ? "$wordsPerMinuteString" : "??",
toolTip: L10n.of(context)!.wordsPerMinute,
onPressed: () => MatrixState.pangeaController.instructions.show(
onPressed: () => MatrixState.pangeaController.instructions
.showInstructionsPopup(
context,
InstructionsEnum.tooltipInstructions,
widget.messageEvent.eventId,
@ -193,6 +202,11 @@ class MessageSpeechToTextCardState extends State<MessageSpeechToTextCard> {
),
],
),
MatrixState.pangeaController.instructions.getInstructionInlineTooltip(
context,
InstructionsEnum.speechToText,
closeHint,
),
],
);
}

Some files were not shown because too many files have changed in this diff Show more