diff --git a/README.md b/README.md index 7c27b6e2e..a1ad9f2b7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ # Special thanks -* Pangea Chat is a fork of [FluffyChat](https://fluffychat.im), is an open source, nonprofit and cute [[matrix](https://matrix.org)] client written in [Flutter](https://flutter.dev). The goal of FluffyChat is to create an easy to use instant messenger which is open source and accessible for everyone. You can [support the primary maker of FluffyChat directly here.](https://ko-fi.com/C1C86VN53) +* Pangea Chat is a fork of [FluffyChat](https://fluffychat.im) which is a [[matrix](https://matrix.org)] client written in [Flutter](https://flutter.dev). You can [support the primary maker of FluffyChat directly here.](https://ko-fi.com/C1C86VN53) * Fabiyamada is a graphics designer and has made the fluffychat logo and the banner. Big thanks for her great designs. diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 4a27fd6da..cb319c9c7 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4010,9 +4010,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.", @@ -4055,5 +4055,8 @@ "spaceAnalytics": "Space Analytics", "changeAnalyticsLanguage": "Change Analytics Language", "suggestToSpace": "Suggest this space", - "suggestToSpaceDesc": "Suggested spaces will appear in the chat lists for their parent spaces" + "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" } \ No newline at end of file diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb index 0658bff50..dc328294a 100644 --- a/assets/l10n/intl_es.arb +++ b/assets/l10n/intl_es.arb @@ -4609,9 +4609,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.", diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index dc129a192..c5756438c 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -3,6 +3,7 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/enum/use_type.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/language_model.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_buttons.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/string_color.dart'; @@ -524,7 +525,14 @@ class Message extends StatelessWidget { Widget container; final showReceiptsRow = event.hasAggregatedEvents(timeline, RelationshipTypes.reaction); - if (showReceiptsRow || displayTime || selected || displayReadMarker) { + // #Pangea + // if (showReceiptsRow || displayTime || selected || displayReadMarker) { + if (showReceiptsRow || + displayTime || + selected || + displayReadMarker || + (pangeaMessageEvent?.showMessageButtons ?? false)) { + // Pangea# container = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: @@ -561,7 +569,11 @@ class Message extends StatelessWidget { AnimatedSize( duration: FluffyThemes.animationDuration, curve: FluffyThemes.animationCurve, - child: !showReceiptsRow + // #Pangea + child: !showReceiptsRow && + !(pangeaMessageEvent?.showMessageButtons ?? false) + // child: !showReceiptsRow + // Pangea# ? const SizedBox.shrink() : Padding( padding: EdgeInsets.only( @@ -569,7 +581,19 @@ class Message extends StatelessWidget { left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0, right: ownMessage ? 0 : 12.0, ), - child: MessageReactions(event, timeline), + // #Pangea + child: Row( + mainAxisAlignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + if (pangeaMessageEvent?.showMessageButtons ?? false) + MessageButtons(toolbarController: toolbarController), + MessageReactions(event, timeline), + ], + ), + // child: MessageReactions(event, timeline), + // Pangea# ), ), if (displayReadMarker) diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 9d7ff2440..569eba880 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; @@ -213,32 +212,12 @@ class ChatListController extends State } List get filteredRooms => Matrix.of(context) - .client - .rooms - .where( - getRoomFilterByActiveFilter(activeFilter), - ) - // #Pangea - .sorted((roomA, roomB) { - // put rooms with unread messages at the top of the list - if (roomA.membership == Membership.invite && - roomB.membership != Membership.invite) { - return -1; - } - if (roomA.membership != Membership.invite && - roomB.membership == Membership.invite) { - return 1; - } - - final bool aUnread = roomA.notificationCount > 0 || roomA.markedUnread; - final bool bUnread = roomB.notificationCount > 0 || roomB.markedUnread; - if (aUnread && !bUnread) return -1; - if (!aUnread && bUnread) return 1; - - return 0; - }) - // Pangea# - .toList(); + .client + .rooms + .where( + getRoomFilterByActiveFilter(activeFilter), + ) + .toList(); bool isSearchMode = false; Future? publicRoomsResponse; diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index e45b38c33..4ad107f6b 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -1,5 +1,6 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/utils/find_conversation_partner_dialog.dart'; import 'package:fluffychat/pangea/utils/logout.dart'; import 'package:fluffychat/pangea/utils/space_code.dart'; @@ -68,7 +69,9 @@ class ClientChooserButton extends StatelessWidget { ), ), PopupMenuItem( - enabled: matrix.client.rooms.isNotEmpty, + enabled: matrix.client.rooms.any( + (room) => !room.isSpace && !room.isArchived && !room.isAnalyticsRoom, + ), value: SettingsAction.myAnalytics, child: Row( children: [ diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 6ea029a6f..a0c6a5f6b 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/choreographer/controllers/message_options.dart import 'package:fluffychat/pangea/constants/language_keys.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'; @@ -570,13 +571,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, -} diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index 54fd601b9..c26fd706d 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -3,9 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; import '../../utils/bot_style.dart'; import 'it_shimmer.dart'; @@ -18,6 +16,10 @@ class ChoicesArray extends StatelessWidget { final int? selectedChoiceIndex; final String originalSpan; final String Function(int) uniqueKeyForLayerLink; + + /// some uses of this widget want to disable the choices + final bool isActive; + const ChoicesArray({ super.key, required this.isLoading, @@ -26,6 +28,7 @@ class ChoicesArray extends StatelessWidget { required this.originalSpan, required this.uniqueKeyForLayerLink, required this.selectedChoiceIndex, + this.isActive = true, this.onLongPress, }); @@ -42,8 +45,8 @@ class ChoicesArray extends StatelessWidget { .map( (entry) => ChoiceItem( theme: theme, - onLongPress: onLongPress, - onPressed: onPressed, + onLongPress: isActive ? onLongPress : null, + onPressed: isActive ? onPressed : (_) {}, entry: entry, isSelected: selectedChoiceIndex == entry.key, ), @@ -109,19 +112,19 @@ class ChoiceItem extends StatelessWidget { : null, child: TextButton( style: ButtonStyle( - padding: MaterialStateProperty.all( + padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 7), ), //if index is selected, then give the background a slight primary color - backgroundColor: MaterialStateProperty.all( + backgroundColor: WidgetStateProperty.all( entry.value.color != null ? entry.value.color!.withOpacity(0.2) : theme.colorScheme.primary.withOpacity(0.1), ), - textStyle: MaterialStateProperty.all( + textStyle: WidgetStateProperty.all( BotStyle.text(context), ), - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), @@ -177,21 +180,21 @@ class ChoiceAnimationWidgetState extends State ); _animation = widget.isGold - ? Tween(begin: 1.0, end: 1.2).animate(_controller) - : TweenSequence([ - TweenSequenceItem( - tween: Tween(begin: 0, end: -8 * pi / 180), - weight: 1.0, - ), - TweenSequenceItem( - tween: Tween(begin: -8 * pi / 180, end: 16 * pi / 180), - weight: 2.0, - ), - TweenSequenceItem( - tween: Tween(begin: 16 * pi / 180, end: 0), - weight: 1.0, - ), - ]).animate(_controller); + ? Tween(begin: 1.0, end: 1.2).animate(_controller) + : TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 0, end: -8 * pi / 180), + weight: 1.0, + ), + TweenSequenceItem( + tween: Tween(begin: -8 * pi / 180, end: 16 * pi / 180), + weight: 2.0, + ), + TweenSequenceItem( + tween: Tween(begin: 16 * pi / 180, end: 0), + weight: 1.0, + ), + ]).animate(_controller); if (widget.selected && !animationPlayed) { _controller.forward(); @@ -221,28 +224,28 @@ class ChoiceAnimationWidgetState extends State @override Widget build(BuildContext context) { return widget.isGold - ? AnimatedBuilder( - key: UniqueKey(), - animation: _animation, - builder: (context, child) { - return Transform.scale( - scale: _animation.value, - child: child, - ); - }, - child: widget.child, - ) - : AnimatedBuilder( - key: UniqueKey(), - animation: _animation, - builder: (context, child) { - return Transform.rotate( - angle: _animation.value, - child: child, - ); - }, - child: widget.child, - ); + ? AnimatedBuilder( + key: UniqueKey(), + animation: _animation, + builder: (context, child) { + return Transform.scale( + scale: _animation.value, + child: child, + ); + }, + child: widget.child, + ) + : AnimatedBuilder( + key: UniqueKey(), + animation: _animation, + builder: (context, child) { + return Transform.rotate( + angle: _animation.value, + child: child, + ); + }, + child: widget.child, + ); } @override diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart index 49e4b078d..877158dd2 100644 --- a/lib/pangea/choreographer/widgets/start_igc_button.dart +++ b/lib/pangea/choreographer/widgets/start_igc_button.dart @@ -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 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,7 +88,7 @@ class StartIGCButtonState extends State disabledElevation: 0, shape: const CircleBorder(), onPressed: () { - if (assistanceState != AssistanceState.complete) { + if (assistanceState != AssistanceState.fetching) { widget.controller.choreographer .getLanguageHelp( false, @@ -142,32 +141,3 @@ class StartIGCButtonState extends State ); } } - -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; - } - } -} diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 7451a2a70..27b177a35 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -25,4 +25,8 @@ 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"; + static const activityRecord = "pangea.activity_completion"; } diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index 1d1a00c05..7fbf91fd0 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:math'; @@ -12,6 +13,8 @@ import 'package:fluffychat/pangea/controllers/local_settings.dart'; import 'package:fluffychat/pangea/controllers/message_data_controller.dart'; import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/permissions_controller.dart'; +import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart'; +import 'package:fluffychat/pangea/controllers/practice_activity_record_controller.dart'; import 'package:fluffychat/pangea/controllers/speech_to_text_controller.dart'; import 'package:fluffychat/pangea/controllers/subscription_controller.dart'; import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; @@ -53,6 +56,8 @@ class PangeaController { late TextToSpeechController textToSpeech; late SpeechToTextController speechToText; late LanguageDetectionController languageDetection; + late PracticeActivityRecordController activityRecordController; + late PracticeGenerationController practiceGenerationController; ///store Services late PLocalStore pStoreService; @@ -101,6 +106,8 @@ class PangeaController { textToSpeech = TextToSpeechController(this); speechToText = SpeechToTextController(this); languageDetection = LanguageDetectionController(this); + activityRecordController = PracticeActivityRecordController(this); + practiceGenerationController = PracticeGenerationController(); PAuthGaurd.pController = this; } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart new file mode 100644 index 000000000..29047d0c4 --- /dev/null +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:matrix/matrix.dart'; + +/// Represents an item in the completion cache. +class _RequestCacheItem { + PracticeActivityRequest req; + + Future practiceActivityEvent; + + _RequestCacheItem({ + required this.req, + required this.practiceActivityEvent, + }); +} + +/// Controller for handling activity completions. +class PracticeGenerationController { + static final Map _cache = {}; + Timer? _cacheClearTimer; + + PracticeGenerationController() { + _initializeCacheClearing(); + } + + void _initializeCacheClearing() { + const duration = Duration(minutes: 2); + _cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache()); + } + + void _clearCache() { + _cache.clear(); + } + + void dispose() { + _cacheClearTimer?.cancel(); + } + + Future _sendAndPackageEvent( + PracticeActivityModel model, + PangeaMessageEvent pangeaMessageEvent, + ) async { + final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent( + content: model.toJson(), + parentEventId: pangeaMessageEvent.eventId, + type: PangeaEventTypes.pangeaActivityRes, + ); + + if (activityEvent == null) { + return null; + } + + return PracticeActivityEvent( + event: activityEvent, + timeline: pangeaMessageEvent.timeline, + ); + } + + Future getPracticeActivity( + PracticeActivityRequest req, + PangeaMessageEvent event, + ) async { + final int cacheKey = req.hashCode; + + if (_cache.containsKey(cacheKey)) { + return _cache[cacheKey]!.practiceActivityEvent; + } else { + //TODO - send request to server/bot, either via API or via event of type pangeaActivityReq + // for now, just make and send the event from the client + final Future eventFuture = + _sendAndPackageEvent(dummyModel(event), event); + + _cache[cacheKey] = + _RequestCacheItem(req: req, practiceActivityEvent: eventFuture); + + return _cache[cacheKey]!.practiceActivityEvent; + } + } + + PracticeActivityModel dummyModel(PangeaMessageEvent event) => + PracticeActivityModel( + tgtConstructs: [ + ConstructIdentifier(lemma: "be", type: ConstructType.vocab), + ], + activityType: ActivityTypeEnum.multipleChoice, + langCode: event.messageDisplayLangCode, + msgId: event.eventId, + multipleChoice: MultipleChoice( + question: "What is a synonym for 'happy'?", + choices: ["sad", "angry", "joyful", "tired"], + answer: "joyful", + ), + ); +} diff --git a/lib/pangea/controllers/practice_activity_record_controller.dart b/lib/pangea/controllers/practice_activity_record_controller.dart new file mode 100644 index 000000000..b075fa553 --- /dev/null +++ b/lib/pangea/controllers/practice_activity_record_controller.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.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'; + +/// Represents an item in the completion cache. +class _RecordCacheItem { + PracticeActivityRecordModel data; + + Future recordEvent; + + _RecordCacheItem({required this.data, required this.recordEvent}); +} + +/// Controller for handling activity completions. +class PracticeActivityRecordController { + static final Map _cache = {}; + late final PangeaController _pangeaController; + Timer? _cacheClearTimer; + + PracticeActivityRecordController(this._pangeaController) { + _initializeCacheClearing(); + } + + void _initializeCacheClearing() { + const duration = Duration(minutes: 2); + _cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache()); + } + + void _clearCache() { + _cache.clear(); + } + + void dispose() { + _cacheClearTimer?.cancel(); + } + + /// Sends a practice activity record to the server and returns the corresponding event. + /// + /// The [recordModel] parameter is the model representing the practice activity record. + /// The [practiceActivityEvent] parameter is the event associated with the practice activity. + /// Note that the system will send a new event if the model has changed in any way ie it is + /// a new completion of the practice activity. However, it will cache previous sends to ensure + /// that opening and closing of the widget does not result in multiple sends of the same data. + /// It allows checks the data to make sure that it contains responses to the practice activity + /// and does not represent a blank record with no actual completion to be saved. + /// + /// Returns a [Future] that completes with the corresponding [Event] object. + Future send( + PracticeActivityRecordModel recordModel, + PracticeActivityEvent practiceActivityEvent, + ) async { + final int cacheKey = recordModel.hashCode; + + if (recordModel.responses.isEmpty) { + return null; + } + + if (_cache.containsKey(cacheKey)) { + return _cache[cacheKey]!.recordEvent; + } else { + final Future eventFuture = practiceActivityEvent.event.room + .sendPangeaEvent( + content: recordModel.toJson(), + parentEventId: practiceActivityEvent.event.eventId, + type: PangeaEventTypes.activityRecord, + ) + .catchError((e) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: e, + s: StackTrace.current, + data: { + 'recordModel': recordModel.toJson(), + 'practiceActivityEvent': practiceActivityEvent.event.toJson(), + }, + ); + return null; + }); + + _cache[cacheKey] = + _RecordCacheItem(data: recordModel, recordEvent: eventFuture); + + return _cache[cacheKey]!.recordEvent; + } + } +} diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart new file mode 100644 index 000000000..d429aa038 --- /dev/null +++ b/lib/pangea/enum/activity_type_enum.dart @@ -0,0 +1,16 @@ +enum ActivityTypeEnum { multipleChoice, freeResponse, listening, speaking } + +extension ActivityTypeExtension on ActivityTypeEnum { + String get string { + switch (this) { + case ActivityTypeEnum.multipleChoice: + return 'multiple_choice'; + case ActivityTypeEnum.freeResponse: + return 'free_response'; + case ActivityTypeEnum.listening: + return 'listening'; + case ActivityTypeEnum.speaking: + return 'speaking'; + } + } +} diff --git a/lib/pangea/enum/assistance_state_enum.dart b/lib/pangea/enum/assistance_state_enum.dart new file mode 100644 index 000000000..6d3a853da --- /dev/null +++ b/lib/pangea/enum/assistance_state_enum.dart @@ -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; + } + } +} diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index 25948d23b..58753e5b5 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -1,9 +1,16 @@ +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix.dart'; -enum MessageMode { translation, definition, speechToText, textToSpeech } +enum MessageMode { + translation, + definition, + speechToText, + textToSpeech, + practiceActivity +} extension MessageModeExtension on MessageMode { IconData get icon { @@ -17,6 +24,8 @@ extension MessageModeExtension on MessageMode { //TODO change icon for audio messages case MessageMode.definition: return Icons.book; + case MessageMode.practiceActivity: + return Symbols.fitness_center; default: return Icons.error; // Icon to indicate an error or unsupported mode } @@ -32,6 +41,8 @@ extension MessageModeExtension on MessageMode { return L10n.of(context)!.speechToTextTooltip; case MessageMode.definition: return L10n.of(context)!.definitions; + case MessageMode.practiceActivity: + return L10n.of(context)!.practice; default: return L10n.of(context)! .oopsSomethingWentWrong; // Title to indicate an error or unsupported mode @@ -48,6 +59,8 @@ extension MessageModeExtension on MessageMode { return L10n.of(context)!.speechToTextTooltip; case MessageMode.definition: return L10n.of(context)!.define; + case MessageMode.practiceActivity: + return L10n.of(context)!.practice; default: return L10n.of(context)! .oopsSomethingWentWrong; // Title to indicate an error or unsupported mode @@ -58,6 +71,7 @@ extension MessageModeExtension on MessageMode { switch (this) { case MessageMode.translation: case MessageMode.textToSpeech: + case MessageMode.practiceActivity: case MessageMode.definition: return event.messageType == MessageTypes.Text; case MessageMode.speechToText: @@ -66,4 +80,29 @@ extension MessageModeExtension on MessageMode { return true; } } + + Color? iconColor( + PangeaMessageEvent event, + MessageMode? currentMode, + BuildContext context, + ) { + final bool isPracticeActivity = this == MessageMode.practiceActivity; + final bool practicing = currentMode == MessageMode.practiceActivity; + final bool practiceEnabled = event.hasUncompletedActivity; + + // if this is the practice activity icon, and there's no practice activities available, + // and the current mode is not practice, return lower opacity color. + if (isPracticeActivity && !practicing && !practiceEnabled) { + return Theme.of(context).iconTheme.color?.withOpacity(0.5); + } + + // if this is not a practice activity icon, and practice activities are available, + // then return lower opacity color if the current mode is practice. + if (!isPracticeActivity && practicing && practiceEnabled) { + return Theme.of(context).iconTheme.color?.withOpacity(0.5); + } + + // if this is the current mode, return primary color. + return currentMode == this ? Theme.of(context).colorScheme.primary : null; + } } diff --git a/lib/pangea/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart index f62f27925..17e14ed86 100644 --- a/lib/pangea/extensions/pangea_event_extension.dart +++ b/lib/pangea/extensions/pangea_event_extension.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/models/choreo_record.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/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:flutter/foundation.dart'; @@ -26,7 +28,12 @@ extension PangeaEvent on Event { return PangeaRepresentation.fromJson(json) as V; case PangeaEventTypes.choreoRecord: return ChoreoRecord.fromJson(json) as V; + case PangeaEventTypes.pangeaActivityRes: + return PracticeActivityModel.fromJson(json) as V; + case PangeaEventTypes.activityRecord: + return PracticeActivityRecordModel.fromJson(json) as V; default: + debugger(when: kDebugMode); throw Exception("$type events do not have pangea content"); } } diff --git a/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart b/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart deleted file mode 100644 index 3583d021e..000000000 --- a/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart +++ /dev/null @@ -1,9 +0,0 @@ -// relates to a pangea representation event -// the matrix even fits the form of a regular matrix audio event -// but with something to distinguish it as a pangea audio event - -import 'package:matrix/matrix.dart'; - -class PangeaAudioEvent { - Event? _event; -} diff --git a/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart b/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart index 47a6b688f..a6a79fd4f 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; @@ -23,7 +25,7 @@ class ChoreoEvent { _content ??= event.getPangeaContent(); return _content; } catch (err, s) { - if (kDebugMode) rethrow; + debugger(when: kDebugMode); ErrorHandler.logError(e: err, s: s); return null; } diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index b22aa7491..951b77dfc 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; @@ -6,6 +7,7 @@ import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/enum/audio_encoding_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; @@ -14,6 +16,7 @@ import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -549,12 +552,42 @@ class PangeaMessageEvent { _event.messageType != PangeaEventTypes.report && _event.messageType == MessageTypes.Text; + // this is just showActivityIcon now but will include + // logic for showing + bool get showMessageButtons => hasUncompletedActivity; + + /// Returns a boolean value indicating whether to show an activity icon for this message event. + /// + /// The [hasUncompletedActivity] getter checks if the [l2Code] is null, and if so, returns false. + /// Otherwise, it retrieves a list of [PracticeActivityEvent] objects using the [practiceActivities] function + /// with the [l2Code] as an argument. + /// If the list is empty, it returns false. + /// Otherwise, it checks if every activity in the list is complete using the [isComplete] property. + /// 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 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); + } + + String? get l2Code => + MatrixState.pangeaController.languageController.activeL2Code(); + String get messageDisplayLangCode { final bool immersionMode = MatrixState .pangeaController.permissionsController .isToolEnabled(ToolSetting.immersionMode, room); - final String? l2Code = - MatrixState.pangeaController.languageController.activeL2Code(); + final String? originalLangCode = (originalWritten ?? originalSent)?.langCode; @@ -578,6 +611,53 @@ class PangeaMessageEvent { return steps; } + List get _practiceActivityEvents => _latestEdit + .aggregatedEvents( + timeline, + PangeaEventTypes.pangeaActivityRes, + ) + .map( + (e) => PracticeActivityEvent( + timeline: timeline, + event: e, + ), + ) + .toList(); + + bool get hasActivities { + try { + final String? l2code = + MatrixState.pangeaController.languageController.activeL2Code(); + + if (l2code == null) return false; + + return practiceActivities(l2code).isNotEmpty; + } catch (e, s) { + ErrorHandler.logError(e: e, s: s); + return false; + } + } + + List practiceActivities( + String langCode, { + bool debug = false, + }) { + try { + debugger(when: debug); + final List activities = []; + for (final event in _practiceActivityEvents) { + if (event.practiceActivity.langCode == langCode) { + activities.add(event); + } + } + return activities; + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s, data: event.toJson()); + return []; + } + } + // List 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 diff --git a/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart b/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart index 0c138c637..f617b8dae 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart @@ -1,6 +1,9 @@ +import 'dart:developer'; + import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import '../constants/pangea_event_types.dart'; @@ -22,6 +25,7 @@ class TokensEvent { _content ??= event.getPangeaContent(); return _content!; } catch (err, s) { + debugger(when: kDebugMode); ErrorHandler.logError(e: err, s: s); return null; } diff --git a/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart b/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart new file mode 100644 index 000000000..d4b9cde23 --- /dev/null +++ b/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart @@ -0,0 +1,24 @@ +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(); + return _content!; + } +} diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart new file mode 100644 index 000000000..c5f35be91 --- /dev/null +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -0,0 +1,67 @@ +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/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:flutter/foundation.dart'; +import 'package:matrix/matrix.dart'; + +import '../constants/pangea_event_types.dart'; + +class PracticeActivityEvent { + Event event; + Timeline? timeline; + PracticeActivityModel? _content; + + PracticeActivityEvent({ + required this.event, + required this.timeline, + content, + }) { + if (content != null) { + if (!kDebugMode) { + throw Exception( + "content should not be set on product, just a dev placeholder", + ); + } else { + _content = content; + } + } + if (event.type != PangeaEventTypes.pangeaActivityRes) { + throw Exception( + "${event.type} should not be used to make a PracticeActivityEvent", + ); + } + } + + PracticeActivityModel get practiceActivity { + _content ??= event.getPangeaContent(); + return _content!; + } + + //in aggregatedEvents for the event, find all practiceActivityRecordEvents whose sender matches the client's userId + List get allRecords { + if (timeline == null) { + debugger(when: kDebugMode); + return []; + } + final List records = event + .aggregatedEvents(timeline!, PangeaEventTypes.activityRecord) + .toList(); + + return records + .map((event) => PracticeActivityRecordEvent(event: event)) + .toList(); + } + + List get userRecords => allRecords + .where( + (recordEvent) => + recordEvent.event.senderId == recordEvent.event.room.client.userID, + ) + .toList(); + + /// 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; +} diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart new file mode 100644 index 000000000..3cd78a66a --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -0,0 +1,41 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:flutter/material.dart'; + +class MultipleChoice { + final String question; + final List choices; + final String answer; + + MultipleChoice({ + required this.question, + required this.choices, + required this.answer, + }); + + bool isCorrect(int index) => index == correctAnswerIndex; + + bool get isValidQuestion => choices.contains(answer); + + int get correctAnswerIndex => choices.indexOf(answer); + + int choiceIndex(String choice) => choices.indexOf(choice); + + Color choiceColor(int index) => + index == correctAnswerIndex ? AppConfig.success : AppConfig.warning; + + factory MultipleChoice.fromJson(Map json) { + return MultipleChoice( + question: json['question'] as String, + choices: (json['choices'] as List).map((e) => e as String).toList(), + answer: json['answer'] as String, + ); + } + + Map toJson() { + return { + 'question': question, + 'choices': choices, + 'answer': answer, + }; + } +} diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart new file mode 100644 index 000000000..ae8455c7f --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -0,0 +1,280 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; + +class ConstructIdentifier { + final String lemma; + final ConstructType type; + + ConstructIdentifier({required this.lemma, required this.type}); + + factory ConstructIdentifier.fromJson(Map json) { + try { + return ConstructIdentifier( + lemma: json['lemma'] as String, + type: ConstructType.values.firstWhere( + (e) => e.string == json['type'], + ), + ); + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s, data: json); + rethrow; + } + } + + Map toJson() { + return { + 'lemma': lemma, + 'type': type.string, + }; + } +} + +class CandidateMessage { + final String msgId; + final String roomId; + final String text; + + CandidateMessage({ + required this.msgId, + required this.roomId, + required this.text, + }); + + factory CandidateMessage.fromJson(Map json) { + return CandidateMessage( + msgId: json['msg_id'] as String, + roomId: json['room_id'] as String, + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'msg_id': msgId, + 'room_id': roomId, + 'text': text, + }; + } +} + +enum PracticeActivityMode { focus, srs } + +extension on PracticeActivityMode { + String get value { + switch (this) { + case PracticeActivityMode.focus: + return 'focus'; + case PracticeActivityMode.srs: + return 'srs'; + } + } +} + +class PracticeActivityRequest { + final PracticeActivityMode? mode; + final List? targetConstructs; + final List? candidateMessages; + final List? userIds; + final ActivityTypeEnum? activityType; + final int? numActivities; + + PracticeActivityRequest({ + this.mode, + this.targetConstructs, + this.candidateMessages, + this.userIds, + this.activityType, + this.numActivities, + }); + + factory PracticeActivityRequest.fromJson(Map json) { + return PracticeActivityRequest( + mode: PracticeActivityMode.values.firstWhere( + (e) => e.value == json['mode'], + ), + targetConstructs: (json['target_constructs'] as List?) + ?.map((e) => ConstructIdentifier.fromJson(e as Map)) + .toList(), + candidateMessages: (json['candidate_msgs'] as List) + .map((e) => CandidateMessage.fromJson(e as Map)) + .toList(), + userIds: (json['user_ids'] as List?)?.map((e) => e as String).toList(), + activityType: ActivityTypeEnum.values.firstWhere( + (e) => e.toString().split('.').last == json['activity_type'], + ), + numActivities: json['num_activities'] as int, + ); + } + + Map toJson() { + return { + 'mode': mode?.value, + 'target_constructs': targetConstructs?.map((e) => e.toJson()).toList(), + 'candidate_msgs': candidateMessages?.map((e) => e.toJson()).toList(), + 'user_ids': userIds, + 'activity_type': activityType?.toString().split('.').last, + 'num_activities': numActivities, + }; + } + + // override operator == and hashCode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PracticeActivityRequest && + other.mode == mode && + other.targetConstructs == targetConstructs && + other.candidateMessages == candidateMessages && + other.userIds == userIds && + other.activityType == activityType && + other.numActivities == numActivities; + } + + @override + int get hashCode { + return mode.hashCode ^ + targetConstructs.hashCode ^ + candidateMessages.hashCode ^ + userIds.hashCode ^ + activityType.hashCode ^ + numActivities.hashCode; + } +} + +class FreeResponse { + final String question; + final String correctAnswer; + final String gradingGuide; + + FreeResponse({ + required this.question, + required this.correctAnswer, + required this.gradingGuide, + }); + + factory FreeResponse.fromJson(Map json) { + return FreeResponse( + question: json['question'] as String, + correctAnswer: json['correct_answer'] as String, + gradingGuide: json['grading_guide'] as String, + ); + } + + Map toJson() { + return { + 'question': question, + 'correct_answer': correctAnswer, + 'grading_guide': gradingGuide, + }; + } +} + +class Listening { + final String audioUrl; + final String text; + + Listening({required this.audioUrl, required this.text}); + + factory Listening.fromJson(Map json) { + return Listening( + audioUrl: json['audio_url'] as String, + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'audio_url': audioUrl, + 'text': text, + }; + } +} + +class Speaking { + final String text; + + Speaking({required this.text}); + + factory Speaking.fromJson(Map json) { + return Speaking( + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'text': text, + }; + } +} + +class PracticeActivityModel { + final List tgtConstructs; + final String langCode; + final String msgId; + final ActivityTypeEnum activityType; + final MultipleChoice? multipleChoice; + final Listening? listening; + final Speaking? speaking; + final FreeResponse? freeResponse; + + PracticeActivityModel({ + required this.tgtConstructs, + required this.langCode, + required this.msgId, + required this.activityType, + this.multipleChoice, + this.listening, + this.speaking, + this.freeResponse, + }); + + factory PracticeActivityModel.fromJson(Map json) { + return PracticeActivityModel( + tgtConstructs: (json['tgt_constructs'] as List) + .map((e) => ConstructIdentifier.fromJson(e as Map)) + .toList(), + langCode: json['lang_code'] as String, + msgId: json['msg_id'] as String, + activityType: ActivityTypeEnum.values.firstWhere( + (e) => e.string == json['activity_type'], + ), + multipleChoice: json['multiple_choice'] != null + ? MultipleChoice.fromJson( + json['multiple_choice'] as Map, + ) + : null, + listening: json['listening'] != null + ? Listening.fromJson(json['listening'] as Map) + : null, + speaking: json['speaking'] != null + ? Speaking.fromJson(json['speaking'] as Map) + : null, + freeResponse: json['free_response'] != null + ? FreeResponse.fromJson( + json['free_response'] as Map, + ) + : null, + ); + } + + Map toJson() { + return { + 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), + 'lang_code': langCode, + 'msg_id': msgId, + 'activity_type': activityType.toString().split('.').last, + 'multiple_choice': multipleChoice?.toJson(), + 'listening': listening?.toJson(), + 'speaking': speaking?.toJson(), + 'free_response': freeResponse?.toJson(), + }; + } +} diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart new file mode 100644 index 000000000..3fe3e859d --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -0,0 +1,137 @@ +// record the options that the user selected +// note that this is not the same as the correct answer +// the user might have selected multiple options before +// finding the answer +import 'dart:developer'; +import 'dart:typed_data'; + +class PracticeActivityRecordModel { + final String? question; + late List responses; + + PracticeActivityRecordModel({ + required this.question, + List? responses, + }) { + if (responses == null) { + this.responses = List.empty(growable: true); + } else { + this.responses = responses; + } + } + + factory PracticeActivityRecordModel.fromJson( + Map json, + ) { + return PracticeActivityRecordModel( + question: json['question'] as String, + responses: (json['responses'] as List) + .map((e) => ActivityResponse.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() { + return { + 'question': question, + 'responses': responses.map((e) => e.toJson()).toList(), + }; + } + + /// 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 { + if (responses.isEmpty) { + return null; + } + responses.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + return responses[responses.length - 1].text; + } + + void addResponse({ + String? text, + Uint8List? audioBytes, + Uint8List? imageBytes, + }) { + try { + responses.add( + ActivityResponse( + text: text, + audioBytes: audioBytes, + imageBytes: imageBytes, + timestamp: DateTime.now(), + ), + ); + } catch (e) { + debugger(); + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PracticeActivityRecordModel && + other.question == question && + other.responses.length == responses.length && + List.generate( + responses.length, + (index) => responses[index] == other.responses[index], + ).every((element) => element); + } + + @override + int get hashCode => question.hashCode ^ responses.hashCode; +} + +class ActivityResponse { + // 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; + + ActivityResponse({ + this.text, + this.audioBytes, + this.imageBytes, + required this.timestamp, + }); + + factory ActivityResponse.fromJson(Map json) { + return ActivityResponse( + text: json['text'] as String?, + audioBytes: json['audio'] as Uint8List?, + imageBytes: json['image'] as Uint8List?, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + } + + Map toJson() { + return { + 'text': text, + 'audio': audioBytes, + 'image': imageBytes, + 'timestamp': timestamp.toIso8601String(), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ActivityResponse && + other.text == text && + other.audioBytes == audioBytes && + other.imageBytes == imageBytes && + other.timestamp == timestamp; + } + + @override + int get hashCode => + text.hashCode ^ + audioBytes.hashCode ^ + imageBytes.hashCode ^ + timestamp.hashCode; +} diff --git a/lib/pangea/widgets/chat/message_buttons.dart b/lib/pangea/widgets/chat/message_buttons.dart new file mode 100644 index 000000000..f7748675f --- /dev/null +++ b/lib/pangea/widgets/chat/message_buttons.dart @@ -0,0 +1,96 @@ +import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:flutter/material.dart'; + +class MessageButtons extends StatelessWidget { + final ToolbarDisplayController? toolbarController; + + const MessageButtons({ + super.key, + this.toolbarController, + }); + + void showActivity(BuildContext context) { + toolbarController?.showToolbar( + context, + mode: MessageMode.practiceActivity, + ); + } + + @override + Widget build(BuildContext context) { + if (toolbarController == null) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + HoverIconButton( + icon: MessageMode.practiceActivity.icon, + onTap: () => showActivity(context), + primaryColor: Theme.of(context).colorScheme.primary, + tooltip: MessageMode.practiceActivity.tooltip(context), + ), + + // Additional buttons can be added here in the future + ], + ), + ); + } +} + +class HoverIconButton extends StatefulWidget { + final IconData icon; + final VoidCallback onTap; + final Color primaryColor; + final String tooltip; + + const HoverIconButton({ + super.key, + required this.icon, + required this.onTap, + required this.primaryColor, + required this.tooltip, + }); + + @override + _HoverIconButtonState createState() => _HoverIconButtonState(); +} + +class _HoverIconButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: widget.tooltip, + child: InkWell( + onTap: widget.onTap, + onHover: (hovering) { + setState(() => _isHovered = hovering); + }, + borderRadius: BorderRadius.circular(100), + child: Container( + decoration: BoxDecoration( + color: _isHovered ? widget.primaryColor : null, + borderRadius: BorderRadius.circular(100), + border: Border.all( + width: 1, + color: widget.primaryColor, + ), + ), + padding: const EdgeInsets.all(2), + child: Icon( + widget.icon, + size: 18, + // when hovered, use themeData to get background color, otherwise use primary + color: _isHovered + ? Theme.of(context).scaffoldBackgroundColor + : widget.primaryColor, + ), + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index ba508906b..434128b1e 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart'; import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; @@ -202,6 +203,12 @@ class MessageToolbarState extends State { return; } + // if there is an uncompleted activity, then show that + // we don't want the user to user the tools to get the answer :P + if (widget.pangeaMessageEvent.hasUncompletedActivity) { + newMode = MessageMode.practiceActivity; + } + if (mounted) { setState(() { currentMode = newMode; @@ -229,6 +236,9 @@ class MessageToolbarState extends State { case MessageMode.definition: showDefinition(); break; + case MessageMode.practiceActivity: + showPracticeActivity(); + break; default: ErrorHandler.logError( e: "Invalid toolbar mode", @@ -286,6 +296,13 @@ class MessageToolbarState extends State { ); } + void showPracticeActivity() { + toolbarContent = PracticeActivityCard( + pangeaMessageEvent: widget.pangeaMessageEvent, + controller: this, + ); + } + void showImage() {} void spellCheck() {} @@ -403,9 +420,11 @@ class MessageToolbarState extends State { message: mode.tooltip(context), child: IconButton( icon: Icon(mode.icon), - color: currentMode == mode - ? Theme.of(context).colorScheme.primary - : null, + color: mode.iconColor( + widget.pangeaMessageEvent, + currentMode, + context, + ), onPressed: () => updateMode(mode), ), ); diff --git a/lib/pangea/widgets/practice_activity/generate_practice_activity.dart b/lib/pangea/widgets/practice_activity/generate_practice_activity.dart new file mode 100644 index 000000000..1eae97d63 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/generate_practice_activity.dart @@ -0,0 +1,61 @@ +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class GeneratePracticeActivityButton extends StatelessWidget { + final PangeaMessageEvent pangeaMessageEvent; + final Function(PracticeActivityEvent?) onActivityGenerated; + + const GeneratePracticeActivityButton({ + super.key, + required this.pangeaMessageEvent, + required this.onActivityGenerated, + }); + + //TODO - probably disable the generation of activities for specific messages + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () async { + final String? l2Code = MatrixState.pangeaController.languageController + .activeL1Model() + ?.langCode; + + if (l2Code == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context)!.noLanguagesSet), + ), + ); + return; + } + + final PracticeActivityEvent? practiceActivityEvent = await MatrixState + .pangeaController.practiceGenerationController + .getPracticeActivity( + PracticeActivityRequest( + candidateMessages: [ + CandidateMessage( + msgId: pangeaMessageEvent.eventId, + roomId: pangeaMessageEvent.room.id, + text: + pangeaMessageEvent.representationByLanguage(l2Code)?.text ?? + pangeaMessageEvent.body, + ), + ], + userIds: pangeaMessageEvent.room.client.userID != null + ? [pangeaMessageEvent.room.client.userID!] + : null, + ), + pangeaMessageEvent, + ); + + onActivityGenerated(practiceActivityEvent); + }, + child: Text(L10n.of(context)!.practice), + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart new file mode 100644 index 000000000..c2861ffe0 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -0,0 +1,69 @@ +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.dart'; +import 'package:flutter/material.dart'; + +class MultipleChoiceActivity extends StatelessWidget { + final MessagePracticeActivityContentState card; + final Function(int) updateChoice; + final bool isActive; + + const MultipleChoiceActivity({ + super.key, + required this.card, + required this.updateChoice, + required this.isActive, + }); + + PracticeActivityEvent get practiceEvent => card.practiceEvent; + + int? get selectedChoiceIndex => card.selectedChoiceIndex; + + bool get submitted => card.recordSubmittedThisSession; + + @override + Widget build(BuildContext context) { + final PracticeActivityModel practiceActivity = + practiceEvent.practiceActivity; + + return Container( + padding: const EdgeInsets.all(8), + child: Column( + children: [ + Text( + practiceActivity.multipleChoice!.question, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ChoicesArray( + isLoading: false, + uniqueKeyForLayerLink: (index) => "multiple_choice_$index", + originalSpan: "placeholder", + onPressed: updateChoice, + selectedChoiceIndex: selectedChoiceIndex, + choices: practiceActivity.multipleChoice!.choices + .mapIndexed( + (index, value) => Choice( + text: value, + color: (selectedChoiceIndex == index || + practiceActivity.multipleChoice! + .isCorrect(index)) && + submitted + ? practiceActivity.multipleChoice!.choiceColor(index) + : null, + isGold: practiceActivity.multipleChoice!.isCorrect(index), + ), + ) + .toList(), + isActive: isActive, + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart new file mode 100644 index 000000000..17c528b22 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -0,0 +1,108 @@ +import 'dart:developer'; + +import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.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'; + +class PracticeActivityCard extends StatefulWidget { + final PangeaMessageEvent pangeaMessageEvent; + final MessageToolbarState controller; + + const PracticeActivityCard({ + super.key, + required this.pangeaMessageEvent, + required this.controller, + }); + + @override + MessagePracticeActivityCardState createState() => + MessagePracticeActivityCardState(); +} + +class MessagePracticeActivityCardState extends State { + PracticeActivityEvent? practiceEvent; + + @override + void initState() { + super.initState(); + loadInitialData(); + } + + String? get langCode { + final String? langCode = MatrixState.pangeaController.languageController + .activeL2Model() + ?.langCode; + + if (langCode == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context)!.noLanguagesSet)), + ); + debugger(when: kDebugMode); + return null; + } + return langCode; + } + + void loadInitialData() { + if (langCode == null) return; + updatePracticeActivity(); + if (practiceEvent == null) { + debugger(when: kDebugMode); + } + } + + void updatePracticeActivity() { + if (langCode == null) return; + final List activities = + widget.pangeaMessageEvent.practiceActivities(langCode!); + if (activities.isEmpty) return; + final List incompleteActivities = + activities.where((element) => !element.isComplete).toList(); + debugPrint("total events: ${activities.length}"); + debugPrint("incomplete practice events: ${incompleteActivities.length}"); + + // TODO update to show next activity + practiceEvent = activities.first; + // // if an incomplete activity is found, show that + // if (incompleteActivities.isNotEmpty) { + // practiceEvent = incompleteActivities.first; + // } + // // if no incomplete activity is found, show the last activity + // else if (activities.isNotEmpty) { + // practiceEvent = activities.last; + // } + setState(() {}); + } + + void showNextActivity() { + if (langCode == null) return; + updatePracticeActivity(); + widget.controller.updateMode(MessageMode.practiceActivity); + } + + @override + Widget build(BuildContext context) { + if (practiceEvent == null) { + return Text( + L10n.of(context)!.noActivitiesFound, + style: BotStyle.text(context), + ); + // return GeneratePracticeActivityButton( + // pangeaMessageEvent: widget.pangeaMessageEvent, + // onActivityGenerated: updatePracticeActivity, + // ); + } + return PracticeActivityContent( + practiceEvent: practiceEvent!, + pangeaMessageEvent: widget.pangeaMessageEvent, + controller: this, + ); + } +} diff --git a/lib/pangea/widgets/practice_activity/practice_activity_content.dart b/lib/pangea/widgets/practice_activity/practice_activity_content.dart new file mode 100644 index 000000000..8080c27ee --- /dev/null +++ b/lib/pangea/widgets/practice_activity/practice_activity_content.dart @@ -0,0 +1,165 @@ +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class PracticeActivityContent extends StatefulWidget { + final PracticeActivityEvent practiceEvent; + final PangeaMessageEvent pangeaMessageEvent; + final MessagePracticeActivityCardState controller; + + const PracticeActivityContent({ + super.key, + required this.practiceEvent, + required this.pangeaMessageEvent, + required this.controller, + }); + + @override + MessagePracticeActivityContentState createState() => + MessagePracticeActivityContentState(); +} + +class MessagePracticeActivityContentState + extends State { + int? selectedChoiceIndex; + PracticeActivityRecordModel? recordModel; + bool recordSubmittedThisSession = false; + bool recordSubmittedPreviousSession = false; + + PracticeActivityEvent get practiceEvent => widget.practiceEvent; + + @override + void initState() { + super.initState(); + initalizeActivity(); + } + + @override + void didUpdateWidget(covariant PracticeActivityContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.practiceEvent.event.eventId != + widget.practiceEvent.event.eventId) { + initalizeActivity(); + } + } + + void initalizeActivity() { + final PracticeActivityRecordEvent? recordEvent = + widget.practiceEvent.userRecords.firstOrNull; + if (recordEvent?.record == null) { + recordModel = PracticeActivityRecordModel( + question: + widget.practiceEvent.practiceActivity.multipleChoice!.question, + ); + } else { + recordModel = recordEvent!.record; + + //Note that only MultipleChoice activities will have this so we probably should move this logic to the MultipleChoiceActivity widget + selectedChoiceIndex = recordModel?.latestResponse != null + ? widget.practiceEvent.practiceActivity.multipleChoice + ?.choiceIndex(recordModel!.latestResponse!) + : null; + + recordSubmittedPreviousSession = true; + recordSubmittedThisSession = true; + } + setState(() {}); + } + + void updateChoice(int index) { + setState(() { + selectedChoiceIndex = index; + recordModel!.addResponse( + text: widget + .practiceEvent.practiceActivity.multipleChoice!.choices[index], + ); + }); + } + + Widget get activityWidget { + switch (widget.practiceEvent.practiceActivity.activityType) { + case ActivityTypeEnum.multipleChoice: + return MultipleChoiceActivity( + card: this, + updateChoice: updateChoice, + isActive: + !recordSubmittedPreviousSession && !recordSubmittedThisSession, + ); + default: + return const SizedBox.shrink(); + } + } + + void sendRecord() { + MatrixState.pangeaController.activityRecordController + .send( + recordModel!, + widget.practiceEvent, + ) + .catchError((error) { + ErrorHandler.logError( + e: error, + s: StackTrace.current, + data: { + 'recordModel': recordModel?.toJson(), + 'practiceEvent': widget.practiceEvent.event.toJson(), + }, + ); + return null; + }).then((_) => widget.controller.showNextActivity()); + + setState(() { + recordSubmittedThisSession = true; + }); + } + + @override + Widget build(BuildContext context) { + debugPrint( + "MessagePracticeActivityContentState.build with selectedChoiceIndex: $selectedChoiceIndex", + ); + return Column( + children: [ + activityWidget, + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Opacity( + opacity: selectedChoiceIndex != null && + !recordSubmittedThisSession && + !recordSubmittedPreviousSession + ? 1.0 + : 0.5, + child: TextButton( + onPressed: () { + if (recordSubmittedThisSession || + recordSubmittedPreviousSession) { + return; + } + selectedChoiceIndex != null ? sendRecord() : null; + }, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + AppConfig.primaryColor, + ), + ), + child: Text(L10n.of(context)!.submit), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/user_settings/p_language_dialog.dart b/lib/pangea/widgets/user_settings/p_language_dialog.dart index 4d09b506e..51082a7e6 100644 --- a/lib/pangea/widgets/user_settings/p_language_dialog.dart +++ b/lib/pangea/widgets/user_settings/p_language_dialog.dart @@ -98,7 +98,6 @@ pLanguageDialog(BuildContext parentContext, Function callback) async { Navigator.pop(context); } catch (err, s) { debugger(when: kDebugMode); - //PTODO-Lala add standard error message ErrorHandler.logError(e: err, s: s); rethrow; } finally { diff --git a/needed-translations.txt b/needed-translations.txt index 6dae1ed19..1355bb368 100644 --- a/needed-translations.txt +++ b/needed-translations.txt @@ -860,7 +860,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "be": [ @@ -2357,7 +2360,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "bn": [ @@ -3850,7 +3856,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "bo": [ @@ -5347,7 +5356,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ca": [ @@ -6246,7 +6258,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "cs": [ @@ -7227,7 +7242,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "de": [ @@ -8091,7 +8109,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "el": [ @@ -9539,7 +9560,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "eo": [ @@ -10685,7 +10709,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "es": [ @@ -10697,7 +10724,10 @@ "addConversationBotButtonRemove", "addConversationBotDialogRemoveConfirmation", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "et": [ @@ -11561,7 +11591,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "eu": [ @@ -12427,7 +12460,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "fa": [ @@ -13430,7 +13466,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "fi": [ @@ -14397,7 +14436,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "fil": [ @@ -15720,7 +15762,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "fr": [ @@ -16722,7 +16767,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ga": [ @@ -17853,7 +17901,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "gl": [ @@ -18717,7 +18768,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "he": [ @@ -19967,7 +20021,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "hi": [ @@ -21457,7 +21514,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "hr": [ @@ -22400,7 +22460,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "hu": [ @@ -23280,7 +23343,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ia": [ @@ -24763,7 +24829,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "id": [ @@ -25633,7 +25702,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ie": [ @@ -26887,7 +26959,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "it": [ @@ -27808,7 +27883,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ja": [ @@ -28840,7 +28918,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ka": [ @@ -30191,7 +30272,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ko": [ @@ -31057,7 +31141,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "lt": [ @@ -32089,7 +32176,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "lv": [ @@ -32961,7 +33051,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "nb": [ @@ -34157,7 +34250,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "nl": [ @@ -35117,7 +35213,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "pl": [ @@ -36086,7 +36185,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "pt": [ @@ -37561,7 +37663,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "pt_BR": [ @@ -38431,7 +38536,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "pt_PT": [ @@ -39628,7 +39736,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ro": [ @@ -40632,7 +40743,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ru": [ @@ -41502,7 +41616,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "sk": [ @@ -42765,7 +42882,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "sl": [ @@ -44158,7 +44278,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "sr": [ @@ -45325,7 +45448,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "sv": [ @@ -46226,7 +46352,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "ta": [ @@ -47720,7 +47849,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "th": [ @@ -49168,7 +49300,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "tr": [ @@ -50032,7 +50167,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "uk": [ @@ -50933,7 +51071,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "vi": [ @@ -52282,7 +52423,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "zh": [ @@ -53146,7 +53290,10 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ], "zh_Hant": [ @@ -54291,6 +54438,9 @@ "spaceAnalytics", "changeAnalyticsLanguage", "suggestToSpace", - "suggestToSpaceDesc" + "suggestToSpaceDesc", + "practice", + "noLanguagesSet", + "noActivitiesFound" ] }