From 91d7600c5de9422fa64d8bb51a9f9a312ed549b4 Mon Sep 17 00:00:00 2001 From: William Jordan-Cooley Date: Wed, 12 Jun 2024 17:34:42 -0400 Subject: [PATCH] display, interactivity, saving/fetching of record, and dummy generation all done --- README.md | 2 +- assets/l10n/intl_en.arb | 3 +- lib/pages/chat/events/message.dart | 40 +++-- .../choreographer/widgets/choice_array.dart | 93 +++++------ lib/pangea/constants/pangea_event_types.dart | 3 +- lib/pangea/controllers/pangea_controller.dart | 7 + ...actice_activity_generation_controller.dart | 101 ++++++++++++ .../practice_activity_record_controller.dart | 94 ++++++++++++ .../extensions/pangea_event_extension.dart | 9 +- .../pangea_audio_events.dart | 9 -- .../pangea_choreo_event.dart | 4 +- .../pangea_message_event.dart | 81 +++++----- .../pangea_tokens_event.dart | 4 + .../practice_acitivity_record_event.dart | 24 +++ .../practice_activity_event.dart | 58 +++++-- .../multiple_choice_activity_model.dart | 39 +---- .../practice_activity_model.dart | 72 +++++++-- .../practice_activity_record_model.dart | 127 +++++++++++++++ lib/pangea/widgets/chat/message_buttons.dart | 96 ++++++++++++ lib/pangea/widgets/chat/message_toolbar.dart | 8 +- .../generate_practice_activity.dart | 60 ++++++++ .../message_practice_activity_card.dart | 67 +++++--- .../message_practice_activity_content.dart | 141 +++++++++++++++++ .../multiple_choice_activity.dart | 66 ++++---- .../user_settings/p_language_dialog.dart | 1 - needed-translations.txt | 144 ++++++++++++------ .../fcm_shared_isolate/pubspec.lock | 56 +++++-- 27 files changed, 1116 insertions(+), 293 deletions(-) create mode 100644 lib/pangea/controllers/practice_activity_generation_controller.dart create mode 100644 lib/pangea/controllers/practice_activity_record_controller.dart delete mode 100644 lib/pangea/matrix_event_wrappers/pangea_audio_events.dart create mode 100644 lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart create mode 100644 lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart create mode 100644 lib/pangea/widgets/chat/message_buttons.dart create mode 100644 lib/pangea/widgets/practice_activity_card/generate_practice_activity.dart create mode 100644 lib/pangea/widgets/practice_activity_card/message_practice_activity_content.dart 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 0bb035da0..bf9a112b6 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3964,5 +3964,6 @@ "roomDataMissing": "Some data may be missing from rooms in which you are not a member.", "updatePhoneOS": "You may need to update your device's OS version.", "wordsPerMinute": "Words per minute", - "practice": "Practice" + "practice": "Practice", + "noLanguagesSet": "Please set your target language and try again." } \ No newline at end of file diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 472ef4eb4..c604d9c19 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'; @@ -108,7 +109,7 @@ class Message extends StatelessWidget { final client = Matrix.of(context).client; final ownMessage = event.senderId == client.userID; final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; - var color = Theme.of(context).colorScheme.surfaceVariant; + var color = Theme.of(context).colorScheme.surfaceContainerHighest; final displayTime = event.type == EventTypes.RoomCreate || nextEvent == null || !event.originServerTs.sameEnvironment(nextEvent!.originServerTs); @@ -132,7 +133,7 @@ class Message extends StatelessWidget { final textColor = ownMessage ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onBackground; + : Theme.of(context).colorScheme.onSurface; final rowMainAxisAlignment = ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; @@ -458,7 +459,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: @@ -472,11 +480,8 @@ class Message extends StatelessWidget { child: Center( child: Material( color: displayTime - ? Theme.of(context).colorScheme.background - : Theme.of(context) - .colorScheme - .background - .withOpacity(0.33), + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.surface.withOpacity(0.33), borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), clipBehavior: Clip.antiAlias, @@ -498,7 +503,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( @@ -506,7 +515,18 @@ 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: [ + MessageButtons(toolbarController: toolbarController), + MessageReactions(event, timeline), + ], + ), + // child: MessageReactions(event, timeline), + // Pangea# ), ), if (displayReadMarker) 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/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index df37724b3..344bd3122 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -24,6 +24,7 @@ class PangeaEventTypes { static const String report = 'm.report'; static const textToSpeechRule = "p.rule.text_to_speech"; - static const activityResponse = "pangea.activity_res"; + 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 ad2a27145..b0f65505f 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..403e22d4f --- /dev/null +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/constants/pangea_event_types.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.string), + ], + activityType: ActivityType.multipleChoice, + langCode: event.messageDisplayLangCode, + msgId: event.eventId, + multipleChoice: MultipleChoice( + question: "What is a synonym for 'happy'?", + choices: ["sad", "angry", "joyful", "tired"], + correctAnswer: "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/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart index dcc3a8bec..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,9 +28,12 @@ extension PangeaEvent on Event { return PangeaRepresentation.fromJson(json) as V; case PangeaEventTypes.choreoRecord: return ChoreoRecord.fromJson(json) as V; - case PangeaEventTypes.activityResponse: - return PangeaMessageTokens.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 c874dc93c..66c81bb47 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -4,15 +4,12 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart'; import 'package:fluffychat/pangea/enum/audio_encoding_enum.dart'; -import 'package:fluffychat/pangea/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_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/class_model.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/speech_to_text_models.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; @@ -576,12 +573,35 @@ class PangeaMessageEvent { _event.messageType != PangeaEventTypes.report && _event.messageType == MessageTypes.Text; + // this is just showActivityIcon now but will include + // logic for showing + bool get showMessageButtons => showActivityIcon; + + /// Returns a boolean value indicating whether to show an activity icon for this message event. + /// + /// The [showActivityIcon] 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 showActivityIcon { + if (l2Code == null) return false; + final List activities = practiceActivities(l2Code!); + + if (activities.isEmpty) return false; + + return !activities.every((activity) => activity.isComplete); + } + + String? get l2Code => MatrixState.pangeaController.languageController + .activeL2Code(roomID: room.id); + String get messageDisplayLangCode { final bool immersionMode = MatrixState .pangeaController.permissionsController .isToolEnabled(ToolSetting.immersionMode, room); - final String? l2Code = MatrixState.pangeaController.languageController - .activeL2Code(roomID: room.id); final String? originalLangCode = (originalWritten ?? originalSent)?.langCode; @@ -608,47 +628,34 @@ class PangeaMessageEvent { List get _practiceActivityEvents => _latestEdit .aggregatedEvents( timeline, - PangeaEventTypes.activityResponse, + PangeaEventTypes.pangeaActivityRes, ) .map( (e) => PracticeActivityEvent( + timeline: timeline, event: e, ), ) .toList(); - List activities(String langCode) { - // final List practiceActivityEvents = _practiceActivityEvents; - - // final List activities = _practiceActivityEvents - // .map( - // (e) => PracticeActivityModel.fromJson( - // e.event.content, - // ), - // ) - // .where( - // (element) => element.langCode == langCode, - // ) - // .toList(); - - // return activities; - - // for now, return a hard-coded activity - final PracticeActivityModel activityModel = PracticeActivityModel( - tgtConstructs: [ - ConstructIdentifier(lemma: "be", type: ConstructType.vocab.string), - ], - activityType: ActivityType.multipleChoice, - langCode: langCode, - msgId: _event.eventId, - multipleChoice: MultipleChoice( - question: "What is a synonym for 'happy'?", - choices: ["sad", "angry", "joyful", "tired"], - correctAnswer: "joyful", - ), - ); + bool get hasActivities { + try { + final String? l2code = MatrixState.pangeaController.languageController + .activeL2Code(roomID: room.id); - return [activityModel]; + if (l2code == null) return false; + + return practiceActivities(l2code).isNotEmpty; + } catch (e, s) { + ErrorHandler.logError(e: e, s: s); + return false; + } + } + + List practiceActivities(String langCode) { + return _practiceActivityEvents + .where((ev) => ev.practiceActivity.langCode == langCode) + .toList(); } // List get activities => 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 index 3e5fa5f16..c5f35be91 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -1,29 +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:fluffychat/pangea/utils/error_handler.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}) { - if (event.type != PangeaEventTypes.activityResponse) { + 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 { - try { - _content ??= event.getPangeaContent(); - return _content!; - } catch (err, s) { - ErrorHandler.logError(e: err, s: s); - return null; + 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 index a0007e754..0cd6aac05 100644 --- a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -1,3 +1,6 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:flutter/material.dart'; + class MultipleChoice { final String question; final List choices; @@ -9,10 +12,15 @@ class MultipleChoice { required this.correctAnswer, }); + bool isCorrect(int index) => index == correctAnswerIndex; + bool get isValidQuestion => choices.contains(correctAnswer); int get correctAnswerIndex => choices.indexOf(correctAnswer); + Color choiceColor(int index) => + index == correctAnswerIndex ? AppConfig.success : AppConfig.warning; + factory MultipleChoice.fromJson(Map json) { return MultipleChoice( question: json['question'] as String, @@ -29,34 +37,3 @@ class MultipleChoice { }; } } - -// 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 -class MultipleChoiceActivityCompletionRecord { - final String question; - List selectedOptions; - - MultipleChoiceActivityCompletionRecord({ - required this.question, - this.selectedOptions = const [], - }); - - factory MultipleChoiceActivityCompletionRecord.fromJson( - Map json, - ) { - return MultipleChoiceActivityCompletionRecord( - question: json['question'] as String, - selectedOptions: - (json['selected_options'] as List).map((e) => e as String).toList(), - ); - } - - Map toJson() { - return { - 'question': question, - 'selected_options': selectedOptions, - }; - } -} diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index 10aaefa87..ebfd68f37 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -23,15 +23,16 @@ class ConstructIdentifier { enum ActivityType { multipleChoice, freeResponse, listening, speaking } -class MessageInfo { +class CandidateMessage { final String msgId; final String roomId; final String text; - MessageInfo({required this.msgId, required this.roomId, required this.text}); + CandidateMessage( + {required this.msgId, required this.roomId, required this.text}); - factory MessageInfo.fromJson(Map json) { - return MessageInfo( + factory CandidateMessage.fromJson(Map json) { + return CandidateMessage( msgId: json['msg_id'] as String, roomId: json['room_id'] as String, text: json['text'] as String, @@ -47,31 +48,46 @@ class MessageInfo { } } -class ActivityRequest { - final String mode; +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? candidateMessages; final List? userIds; final ActivityType? activityType; - final int numActivities; + final int? numActivities; - ActivityRequest({ - required this.mode, + PracticeActivityRequest({ + this.mode, this.targetConstructs, this.candidateMessages, this.userIds, this.activityType, - this.numActivities = 10, + this.numActivities, }); - factory ActivityRequest.fromJson(Map json) { - return ActivityRequest( - mode: json['mode'] as String, + 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) => MessageInfo.fromJson(e as Map)) + .map((e) => CandidateMessage.fromJson(e as Map)) .toList(), userIds: (json['user_ids'] as List?)?.map((e) => e as String).toList(), activityType: ActivityType.values.firstWhere( @@ -83,7 +99,7 @@ class ActivityRequest { Map toJson() { return { - 'mode': mode, + 'mode': mode?.value, 'target_constructs': targetConstructs?.map((e) => e.toJson()).toList(), 'candidate_msgs': candidateMessages?.map((e) => e.toJson()).toList(), 'user_ids': userIds, @@ -91,6 +107,30 @@ class ActivityRequest { '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 { 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..7aa7f6b88 --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -0,0 +1,127 @@ +// 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(), + }; + } + + 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 523637b37..ff7b73b72 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -277,12 +277,8 @@ class MessageToolbarState extends State { } void showPracticeActivity() { - toolbarContent = PracticeActivityCard( - practiceActivity: widget.pangeaMessageEvent - // @ggurdin - is this the best way to get the l2 language here? - .activities(widget.pangeaMessageEvent.messageDisplayLangCode) - .first, - ); + toolbarContent = + PracticeActivityCard(pangeaMessageEvent: widget.pangeaMessageEvent); } void showImage() {} diff --git a/lib/pangea/widgets/practice_activity_card/generate_practice_activity.dart b/lib/pangea/widgets/practice_activity_card/generate_practice_activity.dart new file mode 100644 index 000000000..02d7e90cd --- /dev/null +++ b/lib/pangea/widgets/practice_activity_card/generate_practice_activity.dart @@ -0,0 +1,60 @@ +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, + }); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () async { + final String? l2Code = MatrixState.pangeaController.languageController + .activeL1Model(roomID: pangeaMessageEvent.room.id) + ?.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_card/message_practice_activity_card.dart b/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart index c5bb5dbfa..d69627fcb 100644 --- a/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart @@ -1,15 +1,17 @@ -//stateful widget that displays a card with a practice activity - -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity_card/multiple_choice_activity.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/widgets/practice_activity_card/generate_practice_activity.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity_card/message_practice_activity_content.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class PracticeActivityCard extends StatefulWidget { - final PracticeActivityModel practiceActivity; + final PangeaMessageEvent pangeaMessageEvent; const PracticeActivityCard({ super.key, - required this.practiceActivity, + required this.pangeaMessageEvent, }); @override @@ -17,22 +19,49 @@ class PracticeActivityCard extends StatefulWidget { MessagePracticeActivityCardState(); } -//parameters for the stateful widget -// practiceActivity: the practice activity to display -// use a switch statement based on the type of the practice activity to display the appropriate content -// just use different widgets for the different types, don't define in this file -// for multiple choice, use the MultipleChoiceActivity widget -// for the rest, just return SizedBox.shrink() for now class MessagePracticeActivityCardState extends State { + PracticeActivityEvent? practiceEvent; + + @override + void initState() { + super.initState(); + loadInitialData(); + } + + void loadInitialData() { + final String? langCode = MatrixState.pangeaController.languageController + .activeL2Model(roomID: widget.pangeaMessageEvent.room.id) + ?.langCode; + + if (langCode == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context)!.noLanguagesSet)), + ); + return; + } + + practiceEvent = + widget.pangeaMessageEvent.practiceActivities(langCode).firstOrNull; + setState(() {}); + } + + void updatePracticeActivity(PracticeActivityEvent? newEvent) { + setState(() { + practiceEvent = newEvent; + }); + } + @override Widget build(BuildContext context) { - switch (widget.practiceActivity.activityType) { - case ActivityType.multipleChoice: - return MultipleChoiceActivity( - practiceActivity: widget.practiceActivity, - ); - default: - return const SizedBox.shrink(); + if (practiceEvent == null) { + return GeneratePracticeActivityButton( + pangeaMessageEvent: widget.pangeaMessageEvent, + onActivityGenerated: updatePracticeActivity, + ); } + return PracticeActivityContent( + practiceEvent: practiceEvent!, + pangeaMessageEvent: widget.pangeaMessageEvent, + ); } } diff --git a/lib/pangea/widgets/practice_activity_card/message_practice_activity_content.dart b/lib/pangea/widgets/practice_activity_card/message_practice_activity_content.dart new file mode 100644 index 000000000..fc0119012 --- /dev/null +++ b/lib/pangea/widgets/practice_activity_card/message_practice_activity_content.dart @@ -0,0 +1,141 @@ +import 'package:collection/collection.dart'; +import 'package:fluffychat/config/app_config.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_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:fluffychat/pangea/widgets/practice_activity_card/multiple_choice_activity.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; + + const PracticeActivityContent({ + super.key, + required this.practiceEvent, + required this.pangeaMessageEvent, + }); + + @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(); + final PracticeActivityRecordEvent? recordEvent = + widget.practiceEvent.userRecords.firstOrNull; + if (recordEvent?.record == null) { + recordModel = PracticeActivityRecordModel( + question: + widget.practiceEvent.practiceActivity.multipleChoice!.question, + ); + } else { + recordModel = recordEvent!.record; + recordSubmittedPreviousSession = true; + recordSubmittedThisSession = true; + } + } + + 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 ActivityType.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; + }); + + 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/practice_activity_card/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart index f8bec5436..f74dc03ce 100644 --- a/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart @@ -1,49 +1,39 @@ -// stateful widget that displays a card with a practice activity of type multiple choice - import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.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_card/message_practice_activity_content.dart'; import 'package:flutter/material.dart'; -class MultipleChoiceActivity extends StatefulWidget { - final PracticeActivityModel practiceActivity; +class MultipleChoiceActivity extends StatelessWidget { + final MessagePracticeActivityContentState card; + final Function(int) updateChoice; + final bool isActive; const MultipleChoiceActivity({ super.key, - required this.practiceActivity, + required this.card, + required this.updateChoice, + required this.isActive, }); - @override - MultipleChoiceActivityState createState() => MultipleChoiceActivityState(); -} - -//parameters for the stateful widget -// practiceActivity: the practice activity to display -// show the question text and choices -// use the ChoiceArray widget to display the choices -class MultipleChoiceActivityState extends State { - int? selectedChoiceIndex; + PracticeActivityEvent get practiceEvent => card.practiceEvent; - late MultipleChoiceActivityCompletionRecord? completionRecord; + int? get selectedChoiceIndex => card.selectedChoiceIndex; - @override - initState() { - super.initState(); - selectedChoiceIndex = null; - completionRecord = MultipleChoiceActivityCompletionRecord( - question: widget.practiceActivity.multipleChoice!.question, - ); - } + 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( - widget.practiceActivity.multipleChoice!.question, + practiceActivity.multipleChoice!.question, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -53,26 +43,24 @@ class MultipleChoiceActivityState extends State { ChoicesArray( isLoading: false, uniqueKeyForLayerLink: (index) => "multiple_choice_$index", - onLongPress: null, - onPressed: (index) { - selectedChoiceIndex = index; - completionRecord!.selectedOptions - .add(widget.practiceActivity.multipleChoice!.choices[index]); - setState(() {}); - }, originalSpan: "placeholder", + onPressed: updateChoice, selectedChoiceIndex: selectedChoiceIndex, - choices: widget.practiceActivity.multipleChoice!.choices + choices: practiceActivity.multipleChoice!.choices .mapIndexed( - (int index, String value) => Choice( + (index, value) => Choice( text: value, - color: null, - isGold: - widget.practiceActivity.multipleChoice!.correctAnswer == - 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/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 6a6224756..50198bb38 100644 --- a/needed-translations.txt +++ b/needed-translations.txt @@ -840,7 +840,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "be": [ @@ -2279,7 +2280,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "bn": [ @@ -3180,7 +3182,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "bo": [ @@ -4081,7 +4084,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ca": [ @@ -4982,7 +4986,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "cs": [ @@ -5883,7 +5888,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "de": [ @@ -6731,7 +6737,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "el": [ @@ -7632,7 +7639,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "eo": [ @@ -8533,11 +8541,13 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "es": [ - "practice" + "practice", + "noLanguagesSet" ], "et": [ @@ -9381,7 +9391,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "eu": [ @@ -10225,7 +10236,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "fa": [ @@ -11126,7 +11138,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "fi": [ @@ -12027,7 +12040,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "fr": [ @@ -12928,7 +12942,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ga": [ @@ -13829,7 +13844,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "gl": [ @@ -14673,7 +14689,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "he": [ @@ -15574,7 +15591,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "hi": [ @@ -16475,7 +16493,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "hr": [ @@ -17363,7 +17382,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "hu": [ @@ -18264,7 +18284,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ia": [ @@ -19689,7 +19710,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "id": [ @@ -20590,7 +20612,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ie": [ @@ -21491,7 +21514,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "it": [ @@ -22377,7 +22401,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ja": [ @@ -23278,7 +23303,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ko": [ @@ -24179,7 +24205,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "lt": [ @@ -25080,7 +25107,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "lv": [ @@ -25981,7 +26009,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "nb": [ @@ -26882,7 +26911,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "nl": [ @@ -27783,7 +27813,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "pl": [ @@ -28684,7 +28715,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "pt": [ @@ -29585,7 +29617,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "pt_BR": [ @@ -30455,7 +30488,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "pt_PT": [ @@ -31356,7 +31390,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ro": [ @@ -32257,7 +32292,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ru": [ @@ -33101,7 +33137,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "sk": [ @@ -34002,7 +34039,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "sl": [ @@ -34903,7 +34941,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "sr": [ @@ -35804,7 +35843,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "sv": [ @@ -36670,7 +36710,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "ta": [ @@ -37571,7 +37612,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "th": [ @@ -38472,7 +38514,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "tr": [ @@ -39358,7 +39401,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "uk": [ @@ -40202,7 +40246,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "vi": [ @@ -41103,7 +41148,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "zh": [ @@ -41947,7 +41993,8 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ], "zh_Hant": [ @@ -42848,6 +42895,7 @@ "roomDataMissing", "updatePhoneOS", "wordsPerMinute", - "practice" + "practice", + "noLanguagesSet" ] } diff --git a/pangea_packages/fcm_shared_isolate/pubspec.lock b/pangea_packages/fcm_shared_isolate/pubspec.lock index e18c1dba1..4d8bacbed 100644 --- a/pangea_packages/fcm_shared_isolate/pubspec.lock +++ b/pangea_packages/fcm_shared_isolate/pubspec.lock @@ -128,38 +128,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" matcher: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.16+1" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.8.0" meta: dependency: transitive description: name: meta - sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.0" path: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.9.0" pedantic: dependency: "direct dev" description: @@ -225,10 +249,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" vector_math: dependency: transitive description: @@ -237,14 +261,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - web: + vm_service: dependency: transitive description: - name: web - sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "14.2.1" sdks: - dart: ">=3.2.0-194.0.dev <4.0.0" - flutter: ">=1.20.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54"