From d0e03aea97017944817437c4221628092432b06f Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 27 Jun 2024 15:59:57 -0400 Subject: [PATCH] seperating practice activity-specific logic and functionality from navigation / event sending logic --- lib/pangea/constants/pangea_event_types.dart | 10 +- ...actice_activity_generation_controller.dart | 2 +- .../extensions/pangea_event_extension.dart | 2 +- .../pangea_message_event.dart | 54 +++++---- .../practice_activity_event.dart | 30 +++-- lib/pangea/widgets/chat/message_toolbar.dart | 1 - .../multiple_choice_activity_view.dart | 86 +++++++++++-- .../practice_activity/practice_activity.dart | 68 +---------- .../practice_activity_card.dart | 114 +++++++----------- 9 files changed, 182 insertions(+), 185 deletions(-) diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 27b177a35..9ca975dc0 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -26,7 +26,13 @@ class PangeaEventTypes { static const String report = 'm.report'; static const textToSpeechRule = "p.rule.text_to_speech"; - static const pangeaActivityRes = "pangea.activity_res"; - static const acitivtyRequest = "pangea.activity_req"; + /// A request to the server to generate activities + static const activityRequest = "pangea.activity_req"; + + /// A practice activity that is related to a message + static const pangeaActivity = "pangea.activity_res"; + + /// A record of completion of an activity. There + /// can be one per user per activity. static const activityRecord = "pangea.activity_completion"; } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index 29047d0c4..8ea5b5c82 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -51,7 +51,7 @@ class PracticeGenerationController { final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent( content: model.toJson(), parentEventId: pangeaMessageEvent.eventId, - type: PangeaEventTypes.pangeaActivityRes, + type: PangeaEventTypes.pangeaActivity, ); if (activityEvent == null) { diff --git a/lib/pangea/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart index 17e14ed86..f18ee23b7 100644 --- a/lib/pangea/extensions/pangea_event_extension.dart +++ b/lib/pangea/extensions/pangea_event_extension.dart @@ -28,7 +28,7 @@ extension PangeaEvent on Event { return PangeaRepresentation.fromJson(json) as V; case PangeaEventTypes.choreoRecord: return ChoreoRecord.fromJson(json) as V; - case PangeaEventTypes.pangeaActivityRes: + case PangeaEventTypes.pangeaActivity: return PracticeActivityModel.fromJson(json) as V; case PangeaEventTypes.activityRecord: return PracticeActivityRecordModel.fromJson(json) as V; diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index 306376df8..6621874f7 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -566,10 +566,8 @@ class PangeaMessageEvent { /// If any activity is not complete, it returns true, indicating that the activity icon should be shown. /// Otherwise, it returns false. bool get hasUncompletedActivity { - if (l2Code == null) return false; - final List activities = practiceActivities(l2Code!); - if (activities.isEmpty) return false; - return activities.any((activity) => !(activity.isComplete)); + if (practiceActivities.isEmpty) return false; + return practiceActivities.any((activity) => !(activity.isComplete)); } String? get l2Code => @@ -603,34 +601,36 @@ class PangeaMessageEvent { return steps; } - List get _practiceActivityEvents => _latestEdit - .aggregatedEvents( - timeline, - PangeaEventTypes.pangeaActivityRes, - ) - .map( - (e) => PracticeActivityEvent( - timeline: timeline, - event: e, - ), - ) - .toList(); + /// Returns a list of all [PracticeActivityEvent] objects + /// associated with this message event. + List get _practiceActivityEvents { + return _latestEdit + .aggregatedEvents( + timeline, + PangeaEventTypes.pangeaActivity, + ) + .map( + (e) => PracticeActivityEvent( + timeline: timeline, + event: e, + ), + ) + .toList(); + } + /// Returns a boolean value indicating whether there are any + /// activities associated with this message event for the user's active l2 bool get hasActivities { try { - final String? l2code = - MatrixState.pangeaController.languageController.activeL2Code(); - - if (l2code == null) return false; - - return practiceActivities(l2code).isNotEmpty; + return practiceActivities.isNotEmpty; } catch (e, s) { ErrorHandler.logError(e: e, s: s); return false; } } - List practiceActivities( + /// Returns a list of [PracticeActivityEvent] objects for the given [langCode]. + List practiceActivitiesByLangCode( String langCode, { bool debug = false, }) { @@ -650,6 +650,14 @@ class PangeaMessageEvent { } } + /// Returns a list of [PracticeActivityEvent] for the user's active l2. + List get practiceActivities { + final String? l2code = + MatrixState.pangeaController.languageController.activeL2Code(); + if (l2code == null) return []; + return practiceActivitiesByLangCode(l2code); + } + // 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/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index c5f35be91..10dd814ec 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -27,7 +27,7 @@ class PracticeActivityEvent { _content = content; } } - if (event.type != PangeaEventTypes.pangeaActivityRes) { + if (event.type != PangeaEventTypes.pangeaActivity) { throw Exception( "${event.type} should not be used to make a PracticeActivityEvent", ); @@ -39,7 +39,7 @@ class PracticeActivityEvent { return _content!; } - //in aggregatedEvents for the event, find all practiceActivityRecordEvents whose sender matches the client's userId + /// All completion records assosiated with this activity List get allRecords { if (timeline == null) { debugger(when: kDebugMode); @@ -54,14 +54,24 @@ class PracticeActivityEvent { .toList(); } - List get userRecords => allRecords - .where( - (recordEvent) => - recordEvent.event.senderId == recordEvent.event.room.client.userID, - ) - .toList(); + /// Completion record assosiated with this activity + /// for the logged in user, null if there is none + PracticeActivityRecordEvent? get userRecord { + final List records = allRecords + .where( + (recordEvent) => + recordEvent.event.senderId == + recordEvent.event.room.client.userID, + ) + .toList(); + if (records.length > 1) { + debugPrint("There should only be one record per user per activity"); + debugger(when: kDebugMode); + } + return records.firstOrNull; + } - /// Checks if there are any user records in the list for this activity, + /// Checks if there is a user record for this activity, /// and, if so, then the activity is complete - bool get isComplete => userRecords.isNotEmpty; + bool get isComplete => userRecord != null; } diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 2468d6b96..7c7c7ba6f 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -302,7 +302,6 @@ class MessageToolbarState extends State { void showPracticeActivity() { toolbarContent = PracticeActivityCard( pangeaMessageEvent: widget.pangeaMessageEvent, - controller: this, ); } diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity_view.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity_view.dart index 100da3456..28fc00bd1 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity_view.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity_view.dart @@ -2,29 +2,89 @@ 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.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:flutter/material.dart'; -class MultipleChoiceActivityView extends StatelessWidget { - final PracticeActivityContentState controller; - final Function(int) updateChoice; - final bool isActive; +/// The multiple choice activity view +class MultipleChoiceActivity extends StatefulWidget { + final MessagePracticeActivityCardState controller; + final PracticeActivityEvent? currentActivity; - const MultipleChoiceActivityView({ + const MultipleChoiceActivity({ super.key, required this.controller, - required this.updateChoice, - required this.isActive, + required this.currentActivity, }); - PracticeActivityEvent get practiceEvent => controller.practiceEvent; + @override + MultipleChoiceActivityState createState() => MultipleChoiceActivityState(); +} + +class MultipleChoiceActivityState extends State { + int? selectedChoiceIndex; + + PracticeActivityRecordModel? get currentRecordModel => + widget.controller.currentRecordModel; + + bool get isSubmitted => + widget.currentActivity?.userRecord?.record?.latestResponse != null; + + @override + void initState() { + super.initState(); + setCompletionRecord(); + } - int? get selectedChoiceIndex => controller.selectedChoiceIndex; + @override + void didUpdateWidget(covariant MultipleChoiceActivity oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentActivity?.event.eventId != + widget.currentActivity?.event.eventId) { + setCompletionRecord(); + } + } + + /// Sets the completion record for the multiple choice activity. + /// If the user record is null, it creates a new record model with the question + /// from the current activity and sets the selected choice index to null. + /// Otherwise, it sets the current model to the user record's record and + /// determines the selected choice index. + void setCompletionRecord() { + if (widget.currentActivity?.userRecord?.record == null) { + widget.controller.setCurrentModel( + PracticeActivityRecordModel( + question: + widget.currentActivity?.practiceActivity.multipleChoice!.question, + ), + ); + selectedChoiceIndex = null; + } else { + widget.controller + .setCurrentModel(widget.currentActivity!.userRecord!.record); + selectedChoiceIndex = widget + .currentActivity?.practiceActivity.multipleChoice! + .choiceIndex(currentRecordModel!.latestResponse!); + } + setState(() {}); + } + + void updateChoice(int index) { + currentRecordModel?.addResponse( + text: widget.controller.currentActivity?.practiceActivity.multipleChoice! + .choices[index], + ); + setState(() => selectedChoiceIndex = index); + } @override Widget build(BuildContext context) { - final PracticeActivityModel practiceActivity = - practiceEvent.practiceActivity; + final PracticeActivityModel? practiceActivity = + widget.currentActivity?.practiceActivity; + + if (practiceActivity == null) { + return const SizedBox(); + } return Container( padding: const EdgeInsets.all(8), @@ -55,7 +115,7 @@ class MultipleChoiceActivityView extends StatelessWidget { ), ) .toList(), - isActive: isActive, + isActive: !isSubmitted, ), ], ), diff --git a/lib/pangea/widgets/practice_activity/practice_activity.dart b/lib/pangea/widgets/practice_activity/practice_activity.dart index 5606aceff..1c6b38495 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity.dart @@ -1,20 +1,17 @@ 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_activity_event.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity_view.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:flutter/material.dart'; +/// Practice activity content class PracticeActivity extends StatefulWidget { final PracticeActivityEvent practiceEvent; - final PangeaMessageEvent pangeaMessageEvent; final MessagePracticeActivityCardState controller; const PracticeActivity({ super.key, required this.practiceEvent, - required this.pangeaMessageEvent, required this.controller, }); @@ -23,66 +20,12 @@ class PracticeActivity extends StatefulWidget { } class PracticeActivityContentState extends State { - PracticeActivityEvent get practiceEvent => widget.practiceEvent; - int? selectedChoiceIndex; - bool isSubmitted = false; - - @override - void initState() { - super.initState(); - setRecord(); - } - - @override - void didUpdateWidget(covariant PracticeActivity oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.practiceEvent.event.eventId != - widget.practiceEvent.event.eventId) { - setRecord(); - } - } - - // sets the record model for the activity - // either a new record model that will be sent after submitting the - // activity or the record model from the user's previous response - void setRecord() { - if (widget.controller.recordEvent?.record == null) { - final String question = - practiceEvent.practiceActivity.multipleChoice!.question; - widget.controller.recordModel = - PracticeActivityRecordModel(question: question); - } else { - widget.controller.recordModel = widget.controller.recordEvent!.record; - - // Note that only MultipleChoice activities will have this so we - // probably should move this logic to the MultipleChoiceActivity widget - selectedChoiceIndex = - widget.controller.recordModel?.latestResponse != null - ? widget.practiceEvent.practiceActivity.multipleChoice - ?.choiceIndex(widget.controller.recordModel!.latestResponse!) - : null; - isSubmitted = widget.controller.recordModel?.latestResponse != null; - } - setState(() {}); - } - - void updateChoice(int index) { - setState(() { - selectedChoiceIndex = index; - widget.controller.recordModel!.addResponse( - text: widget - .practiceEvent.practiceActivity.multipleChoice!.choices[index], - ); - }); - } - Widget get activityWidget { switch (widget.practiceEvent.practiceActivity.activityType) { case ActivityTypeEnum.multipleChoice: - return MultipleChoiceActivityView( - controller: this, - updateChoice: updateChoice, - isActive: !isSubmitted, + return MultipleChoiceActivity( + controller: widget.controller, + currentActivity: widget.practiceEvent, ); default: return const SizedBox.shrink(); @@ -91,9 +34,6 @@ class PracticeActivityContentState extends State { @override Widget build(BuildContext context) { - debugPrint( - "MessagePracticeActivityContentState.build with selectedChoiceIndex: $selectedChoiceIndex", - ); return Column( children: [ activityWidget, diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index e28dabe9d..879c96dec 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,29 +1,24 @@ -import 'dart:developer'; - -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_record_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +/// The wrapper for practice activity content. +/// Handles the activities assosiated with a message, +/// their navigation, and the management of completion records class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; - final MessageToolbarState controller; const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, - required this.controller, }); @override @@ -32,13 +27,15 @@ class PracticeActivityCard extends StatefulWidget { } class MessagePracticeActivityCardState extends State { - List practiceActivities = []; - PracticeActivityEvent? practiceEvent; - PracticeActivityRecordModel? recordModel; + PracticeActivityEvent? currentActivity; + PracticeActivityRecordModel? currentRecordModel; bool sending = false; + List get practiceActivities => + widget.pangeaMessageEvent.practiceActivities; + int get practiceEventIndex => practiceActivities.indexWhere( - (activity) => activity.event.eventId == practiceEvent?.event.eventId, + (activity) => activity.event.eventId == currentActivity?.event.eventId, ); bool get isPrevEnabled => @@ -49,80 +46,59 @@ class MessagePracticeActivityCardState extends State { practiceEventIndex >= 0 && practiceEventIndex < practiceActivities.length - 1; - // the first record for this practice activity - // assosiated with the current user - PracticeActivityRecordEvent? get recordEvent => - practiceEvent?.userRecords.firstOrNull; - @override void initState() { super.initState(); - setPracticeActivities(); - } - - 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; + setCurrentActivity(); } - /// Initalizes the practice activities for the current language - /// and sets the first activity as the current activity - void setPracticeActivities() { - if (langCode == null) return; - practiceActivities = - widget.pangeaMessageEvent.practiceActivities(langCode!); + /// Initalizes the current activity. + /// If the current activity hasn't been set yet, show the first + /// uncompleted activity if there is one. + /// If not, show the first activity + void setCurrentActivity() { if (practiceActivities.isEmpty) return; - - practiceActivities.sort( - (a, b) => a.event.originServerTs.compareTo(b.event.originServerTs), - ); - - // if the current activity hasn't been set yet, show the first uncompleted activity - // if there is one. If not, show the first activity final List incompleteActivities = practiceActivities.where((element) => !element.isComplete).toList(); - practiceEvent ??= incompleteActivities.isNotEmpty + currentActivity ??= incompleteActivities.isNotEmpty ? incompleteActivities.first : practiceActivities.first; setState(() {}); } - void navigateActivities({Direction? direction, int? index}) { + void setCurrentModel(PracticeActivityRecordModel? recordModel) { + currentRecordModel = recordModel; + } + + /// Sets the current acitivity based on the given [direction]. + void navigateActivities(Direction direction) { final bool enableNavigation = (direction == Direction.f && isNextEnabled) || - (direction == Direction.b && isPrevEnabled) || - (index != null && index >= 0 && index < practiceActivities.length); + (direction == Direction.b && isPrevEnabled); if (enableNavigation) { - final int newIndex = index ?? - (direction == Direction.f - ? practiceEventIndex + 1 - : practiceEventIndex - 1); - practiceEvent = practiceActivities[newIndex]; + currentActivity = practiceActivities[direction == Direction.f + ? practiceEventIndex + 1 + : practiceEventIndex - 1]; setState(() {}); } } + /// Sends the current record model and activity to the server. + /// If either the currentRecordModel or currentActivity is null, the method returns early. + /// Sets the [sending] flag to true before sending the record and activity. + /// Logs any errors that occur during the send operation. + /// Sets the [sending] flag to false when the send operation is complete. void sendRecord() { - if (recordModel == null || practiceEvent == null) return; + if (currentRecordModel == null || currentActivity == null) return; setState(() => sending = true); MatrixState.pangeaController.activityRecordController - .send(recordModel!, practiceEvent!) + .send(currentRecordModel!, currentActivity!) .catchError((error) { ErrorHandler.logError( e: error, s: StackTrace.current, data: { - 'recordModel': recordModel?.toJson(), - 'practiceEvent': practiceEvent?.event.toJson(), + 'recordModel': currentRecordModel?.toJson(), + 'practiceEvent': currentActivity?.event.toJson(), }, ); return null; @@ -138,20 +114,20 @@ class MessagePracticeActivityCardState extends State { Opacity( opacity: isPrevEnabled ? 1.0 : 0, child: IconButton( - onPressed: isPrevEnabled - ? () => navigateActivities(direction: Direction.b) - : null, + onPressed: + isPrevEnabled ? () => navigateActivities(Direction.b) : null, icon: const Icon(Icons.keyboard_arrow_left_outlined), tooltip: L10n.of(context)!.previous, ), ), Expanded( child: Opacity( - opacity: recordEvent == null ? 1.0 : 0.5, + opacity: currentActivity?.userRecord == null ? 1.0 : 0.5, child: sending ? const CircularProgressIndicator.adaptive() : TextButton( - onPressed: recordEvent == null ? sendRecord : null, + onPressed: + currentActivity?.userRecord == null ? sendRecord : null, style: ButtonStyle( backgroundColor: WidgetStateProperty.all( AppConfig.primaryColor, @@ -164,9 +140,8 @@ class MessagePracticeActivityCardState extends State { Opacity( opacity: isNextEnabled ? 1.0 : 0, child: IconButton( - onPressed: isNextEnabled - ? () => navigateActivities(direction: Direction.f) - : null, + onPressed: + isNextEnabled ? () => navigateActivities(Direction.f) : null, icon: const Icon(Icons.keyboard_arrow_right_outlined), tooltip: L10n.of(context)!.next, ), @@ -174,7 +149,7 @@ class MessagePracticeActivityCardState extends State { ], ); - if (practiceEvent == null || practiceActivities.isEmpty) { + if (currentActivity == null || practiceActivities.isEmpty) { return Text( L10n.of(context)!.noActivitiesFound, style: BotStyle.text(context), @@ -187,8 +162,7 @@ class MessagePracticeActivityCardState extends State { return Column( children: [ PracticeActivity( - pangeaMessageEvent: widget.pangeaMessageEvent, - practiceEvent: practiceEvent!, + practiceEvent: currentActivity!, controller: this, ), navigationButtons,