diff --git a/lib/main.dart b/lib/main.dart index 6be6edc91..36add47dc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,7 +22,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env"); + await dotenv.load(fileName: ".env.local_choreo"); } catch (e) { Logs().e('Failed to load .env file', e); } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index b6256fc03..47998523c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -20,6 +20,7 @@ import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; @@ -652,15 +653,25 @@ class ChatController extends State // There's a listen in my_analytics_controller that decides when to auto-update // analytics based on when / how many messages the logged in user send. This // stream sends the data for newly sent messages. + final metadata = ConstructUseMetaData( + roomId: roomId, + timeStamp: DateTime.now(), + eventId: msgEventId, + ); + if (msgEventId != null) { pangeaController.myAnalytics.setState( AnalyticsStream( eventId: msgEventId, - eventType: EventTypes.Message, roomId: room.id, - originalSent: originalSent, - tokensSent: tokensSent, - choreo: choreo, + constructs: [ + ...(choreo!.grammarConstructUses(metadata: metadata)), + ...(originalSent!.vocabUses( + choreo: choreo, + tokens: tokensSent!.tokens, + metadata: metadata, + )), + ], ), ); } diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index 2bf48b08a..ff13da78f 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -67,25 +67,23 @@ class ChoicesArrayState extends State { : Wrap( alignment: WrapAlignment.center, children: widget.choices! - .mapIndexed( - (index, entry) => ChoiceItem( - theme: theme, - onLongPress: - widget.isActive ? widget.onLongPress : null, - onPressed: widget.isActive - ? widget.onPressed - : (String value, int index) { - debugger(when: kDebugMode); - }, - entry: MapEntry(index, entry), - interactionDisabled: interactionDisabled, - enableInteraction: enableInteractions, - disableInteraction: disableInteraction, - isSelected: widget.selectedChoiceIndex == index, - ), - ) - .toList() ?? - [], + .mapIndexed( + (index, entry) => ChoiceItem( + theme: theme, + onLongPress: widget.isActive ? widget.onLongPress : null, + onPressed: widget.isActive + ? widget.onPressed + : (String value, int index) { + debugger(when: kDebugMode); + }, + entry: MapEntry(index, entry), + interactionDisabled: interactionDisabled, + enableInteraction: enableInteractions, + disableInteraction: disableInteraction, + isSelected: widget.selectedChoiceIndex == index, + ), + ) + .toList(), ); } } diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index a856f89db..cd6991864 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,20 +1,14 @@ import 'dart:async'; import 'package:fluffychat/pangea/constants/local.key.dart'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/base_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/pangea_token_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:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; @@ -29,7 +23,7 @@ class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; CachedStreamController analyticsUpdateStream = CachedStreamController(); - StreamSubscription? _messageSendSubscription; + StreamSubscription? _analyticsStream; Timer? _updateTimer; Client get _client => _pangeaController.matrixState.client; @@ -60,7 +54,7 @@ class MyAnalyticsController extends BaseController { void initialize() { // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user - _messageSendSubscription ??= + _analyticsStream ??= stateStream.listen((data) => _onNewAnalyticsData(data)); _refreshAnalyticsIfOutdated(); @@ -72,8 +66,8 @@ class MyAnalyticsController extends BaseController { _updateTimer?.cancel(); lastUpdated = null; lastUpdatedCompleter = Completer(); - _messageSendSubscription?.cancel(); - _messageSendSubscription = null; + _analyticsStream?.cancel(); + _analyticsStream = null; _refreshAnalyticsIfOutdated(); clearMessagesSinceUpdate(); } @@ -109,34 +103,9 @@ class MyAnalyticsController extends BaseController { /// Given the data from a newly sent message, format and cache /// the message's construct data locally and reset the update timer void _onNewAnalyticsData(AnalyticsStream data) { - // convert that data into construct uses and add it to the cache - final metadata = ConstructUseMetaData( - roomId: data.roomId, - eventId: data.eventId, - timeStamp: DateTime.now(), - ); - final List constructs = _getDraftUses(data.roomId); - if (data.eventType == EventTypes.Message) { - constructs.addAll([ - ...(data.choreo!.grammarConstructUses(metadata: metadata)), - ...(data.originalSent!.vocabUses( - choreo: data.choreo, - tokens: data.tokensSent!.tokens, - metadata: metadata, - )), - ]); - } else if (data.eventType == PangeaEventTypes.activityRecord && - data.practiceActivity != null) { - final activityConstructs = data.recordModel!.uses( - data.practiceActivity!, - metadata: metadata, - ); - constructs.addAll(activityConstructs); - } else { - throw PangeaWarningError("Invalid event type for analytics stream"); - } + constructs.addAll(data.constructs); final String eventID = data.eventId; final String roomID = data.roomId; @@ -342,43 +311,13 @@ class MyAnalyticsController extends BaseController { class AnalyticsStream { final String eventId; - final String eventType; final String roomId; - /// if the event is a message, the original message sent - final PangeaRepresentation? originalSent; - - /// if the event is a message, the tokens sent - final PangeaMessageTokens? tokensSent; - - /// if the event is a message, the choreo record - final ChoreoRecord? choreo; - - /// if the event is a practice activity, the practice activity event - final PracticeActivityEvent? practiceActivity; - - /// if the event is a practice activity, the record model - final PracticeActivityRecordModel? recordModel; + final List constructs; AnalyticsStream({ required this.eventId, - required this.eventType, required this.roomId, - this.originalSent, - this.tokensSent, - this.choreo, - this.practiceActivity, - this.recordModel, - }) { - assert( - (originalSent != null && tokensSent != null && choreo != null) || - (practiceActivity != null && recordModel != null), - "Either a message or a practice activity must be provided", - ); - - assert( - eventType == EventTypes.Message || - eventType == PangeaEventTypes.activityRecord, - ); - } + required this.constructs, + }); } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index fe2ebb388..bdba9a611 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -119,10 +119,28 @@ class PracticeGenerationController { requestModel: req, ); + // if the server points to an existing event, return that event + if (res.existingActivityEventId != null) { + debugPrint( + 'Existing activity event found: ${res.existingActivityEventId}', + ); + final Event? existingEvent = + await event.room.getEventById(res.existingActivityEventId!); + if (existingEvent != null) { + return PracticeActivityEvent( + event: existingEvent, + timeline: event.timeline, + ); + } + } + if (res.activity == null) { + debugPrint('No activity generated'); return null; } + debugPrint('Activity generated: ${res.activity!.toJson()}'); + final Future eventFuture = _sendAndPackageEvent(res.activity!, event); diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index 6616a8c06..2dab65618 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; @@ -62,24 +63,34 @@ class PracticeActivityEvent { /// 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; + List get allUserRecords => allRecords + .where( + (recordEvent) => + recordEvent.event.senderId == recordEvent.event.room.client.userID, + ) + .toList(); + + /// Get the most recent user record for this activity + PracticeActivityRecordEvent? get latestUserRecord { + final List userRecords = allUserRecords; + if (userRecords.isEmpty) return null; + return userRecords.reduce( + (a, b) => a.event.originServerTs.isAfter(b.event.originServerTs) ? a : b, + ); } + DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs; + String get parentMessageId => event.relationshipEventId!; /// Checks if there are any user records in the list for this activity, /// and, if so, then the activity is complete - bool get isComplete => userRecord != null; + bool get isComplete => latestUserRecord != null; + + ExistingActivityMetaData get activityRequestMetaData => + ExistingActivityMetaData( + activityEventId: event.eventId, + tgtConstructs: practiceActivity.tgtConstructs, + activityType: practiceActivity.activityType, + ); } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index e0697d859..b8a73b65a 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -34,13 +34,11 @@ class PangeaToken { int endTokenIndex = -1, ]) { if (endTokenIndex == -1) { - endTokenIndex = tokens.length - 1; + endTokenIndex = tokens.length; } final List subset = - tokens.whereIndexed((int index, PangeaToken token) { - return index >= startTokenIndex && index <= endTokenIndex; - }).toList(); + tokens.sublist(startTokenIndex, endTokenIndex); if (subset.isEmpty) { debugger(when: kDebugMode); @@ -51,10 +49,11 @@ class PangeaToken { return subset.first.text.content; } - String reconstruction = subset.first.text.content; - for (int i = 1; i < subset.length - 1; i++) { + String reconstruction = ""; + for (int i = 0; i < subset.length; i++) { int whitespace = subset[i].text.offset - - (subset[i - 1].text.offset + subset[i - 1].text.length); + (i > 0 ? (subset[i - 1].text.offset + subset[i - 1].text.length) : 0); + if (whitespace < 0) { debugger(when: kDebugMode); whitespace = 0; diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart index 671df8ec6..059f14d20 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -1,11 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; class ConstructWithXP { final ConstructIdentifier id; int xp; - final DateTime? lastUsed; + DateTime? lastUsed; ConstructWithXP({ required this.id, @@ -94,13 +95,52 @@ class TokenWithXP { } } +class ExistingActivityMetaData { + final String activityEventId; + final List tgtConstructs; + final ActivityTypeEnum activityType; + + ExistingActivityMetaData({ + required this.activityEventId, + required this.tgtConstructs, + required this.activityType, + }); + + factory ExistingActivityMetaData.fromJson(Map json) { + return ExistingActivityMetaData( + activityEventId: json['activity_event_id'] as String, + tgtConstructs: (json['tgt_constructs'] as List) + .map((e) => ConstructIdentifier.fromJson(e as Map)) + .toList(), + activityType: ActivityTypeEnum.values.firstWhere( + (element) => + element.string == json['activity_type'] as String || + element.string.split('.').last == json['activity_type'] as String, + ), + ); + } + + Map toJson() { + return { + 'activity_event_id': activityEventId, + 'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(), + 'activity_type': activityType.string, + }; + } +} + class MessageActivityRequest { final String userL1; final String userL2; final String messageText; + + /// tokens with their associated constructs and xp final List tokensWithXP; + /// make the server aware of existing activities for potential reuse + final List existingActivities; + final String messageId; MessageActivityRequest({ @@ -109,6 +149,7 @@ class MessageActivityRequest { required this.messageText, required this.tokensWithXP, required this.messageId, + required this.existingActivities, }); factory MessageActivityRequest.fromJson(Map json) { @@ -120,6 +161,11 @@ class MessageActivityRequest { .map((e) => TokenWithXP.fromJson(e as Map)) .toList(), messageId: json['message_id'] as String, + existingActivities: (json['existing_activities'] as List) + .map( + (e) => ExistingActivityMetaData.fromJson(e as Map), + ) + .toList(), ); } @@ -130,6 +176,7 @@ class MessageActivityRequest { 'message_text': messageText, 'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), 'message_id': messageId, + 'existing_activities': existingActivities.map((e) => e.toJson()).toList(), }; } @@ -152,10 +199,12 @@ class MessageActivityRequest { class MessageActivityResponse { final PracticeActivityModel? activity; final bool finished; + final String? existingActivityEventId; MessageActivityResponse({ required this.activity, required this.finished, + required this.existingActivityEventId, }); factory MessageActivityResponse.fromJson(Map json) { @@ -166,6 +215,7 @@ class MessageActivityResponse { ) : null, finished: json['finished'] as bool, + existingActivityEventId: json['existing_activity_event_id'] as String?, ); } @@ -173,6 +223,7 @@ class MessageActivityResponse { return { 'activity': activity?.toJson(), 'finished': finished, + 'existing_activity_event_id': existingActivityEventId, }; } } 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 index 90c30a17a..1db288973 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -5,12 +5,10 @@ import 'dart:developer'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.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'; class PracticeActivityRecordModel { final String? question; @@ -57,12 +55,6 @@ class PracticeActivityRecordModel { return responses[responses.length - 1]; } - ConstructUseTypeEnum get useType => latestResponse?.score != null - ? (latestResponse!.score > 0 - ? ConstructUseTypeEnum.corPA - : ConstructUseTypeEnum.incPA) - : ConstructUseTypeEnum.unk; - bool hasTextResponse(String text) { return responses.any((element) => element.text == text); } @@ -91,50 +83,50 @@ class PracticeActivityRecordModel { /// Returns a list of [OneConstructUse] objects representing the uses of the practice activity. /// /// The [practiceActivity] parameter is the parent event, representing the activity itself. - /// The [event] parameter is the record event, if available. /// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available. /// - /// If [event] and [metadata] are both null, an empty list is returned. - /// - /// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct. + /// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct and useType. List uses( - PracticeActivityEvent practiceActivity, { - Event? event, - ConstructUseMetaData? metadata, - }) { + PracticeActivityModel practiceActivity, + ConstructUseMetaData metadata, + ) { try { - if (event == null && metadata == null) { - debugger(when: kDebugMode); - return []; - } - final List uses = []; - final List constructIds = - practiceActivity.practiceActivity.tgtConstructs; - for (final construct in constructIds) { - uses.add( - OneConstructUse( - lemma: construct.lemma, - constructType: construct.type, - useType: useType, - //TODO - find form of construct within the message - //this is related to the feature of highlighting the target construct in the message - form: construct.lemma, - metadata: ConstructUseMetaData( - roomId: event?.roomId ?? metadata!.roomId, - eventId: practiceActivity.parentMessageId, - timeStamp: event?.originServerTs ?? metadata!.timeStamp, + final uniqueResponses = responses.toSet(); + + final List useTypes = + uniqueResponses.map((response) => response.useType).toList(); + + for (final construct in practiceActivity.tgtConstructs) { + for (final useType in useTypes) { + uses.add( + OneConstructUse( + lemma: construct.lemma, + constructType: construct.type, + useType: useType, + //TODO - find form of construct within the message + //this is related to the feature of highlighting the target construct in the message + form: construct.lemma, + metadata: metadata, ), - ), - ); + ); + } } return uses; } catch (e, s) { debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: s, data: event?.toJson()); - rethrow; + ErrorHandler.logError( + e: e, + s: s, + data: { + 'recordModel': toJson(), + 'practiceActivity': practiceActivity, + 'metadata': metadata, + }, + ); + return []; } } @@ -172,6 +164,9 @@ class ActivityRecordResponse { required this.timestamp, }); + ConstructUseTypeEnum get useType => + score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + factory ActivityRecordResponse.fromJson(Map json) { return ActivityRecordResponse( text: json['text'] as String?, diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index e1774ac98..6118d5fd9 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -58,7 +58,11 @@ class MessageOverlayController extends State /// Whether the user has completed the activities needed to unlock the toolbar /// within this overlay 'session'. if they click out and come back in then /// we can give them some more activities to complete - bool finishedActivitiesThisSession = false; + int completedThisSession = 0; + + bool get finishedActivitiesThisSession => completedThisSession >= needed; + + late int activitiesLeftToComplete = needed; @override void initState() { @@ -68,17 +72,29 @@ class MessageOverlayController extends State duration: FluffyThemes.animationDuration, ); + activitiesLeftToComplete = + needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; + setInitialToolbarMode(); } - int get activitiesLeftToComplete => - needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted; - bool get isPracticeComplete => activitiesLeftToComplete <= 0; + /// When an activity is completed, we need to update the state + /// and check if the toolbar should be unlocked + void onActivityFinish() { + if (!mounted) return; + completedThisSession += 1; + activitiesLeftToComplete -= 1; + clearSelection(); + setState(() {}); + } + /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { + debugPrint('Exiting practice flow'); + clearSelection(); needed = 0; setInitialToolbarMode(); setState(() {}); @@ -129,6 +145,10 @@ class MessageOverlayController extends State void onClickOverlayMessageToken( PangeaToken token, ) { + if (toolbarMode == MessageMode.practiceActivity) { + return; + } + // if there's no selected span, then select the token if (_selectedSpan == null) { _selectedSpan = token.text; @@ -150,7 +170,7 @@ class MessageOverlayController extends State setState(() {}); } - void onNewActivity(PracticeActivityModel activity) { + void setSelectedSpan(PracticeActivityModel activity) { final RelevantSpanDisplayDetails? span = activity.multipleChoice?.spanDisplayDetails; diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index deef7f232..23db604fd 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -250,7 +250,6 @@ class ToolbarButtonsState extends State { .toList(); static const double iconWidth = 36.0; - double get progressWidth => widget.width / overlayController.needed; MessageOverlayController get overlayController => widget.messageToolbarController.widget.overLayController; @@ -263,6 +262,8 @@ class ToolbarButtonsState extends State { @override Widget build(BuildContext context) { + final double barWidth = widget.width - iconWidth; + if (widget .messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) { return const SizedBox(); @@ -286,11 +287,13 @@ class ToolbarButtonsState extends State { AnimatedContainer( duration: FluffyThemes.animationDuration, height: 12, - width: min( - widget.width, - progressWidth * - pangeaMessageEvent.numberOfActivitiesCompleted, - ), + width: overlayController.isPracticeComplete + ? barWidth + : min( + barWidth, + (barWidth / 3) * + pangeaMessageEvent.numberOfActivitiesCompleted, + ), color: AppConfig.success, margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2), ), diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 65b6f0704..0c426b8ac 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -29,7 +29,7 @@ class MultipleChoiceActivityState extends State { widget.practiceCardController.currentCompletionRecord; bool get isSubmitted => - widget.currentActivity?.userRecord?.record.latestResponse != null; + widget.currentActivity?.latestUserRecord?.record.latestResponse != null; @override void initState() { @@ -52,7 +52,7 @@ class MultipleChoiceActivityState extends State { /// 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) { + if (widget.currentActivity?.latestUserRecord?.record == null) { widget.practiceCardController.setCompletionRecord( PracticeActivityRecordModel( question: @@ -61,8 +61,8 @@ class MultipleChoiceActivityState extends State { ); selectedChoiceIndex = null; } else { - widget.practiceCardController - .setCompletionRecord(widget.currentActivity!.userRecord!.record); + widget.practiceCardController.setCompletionRecord( + widget.currentActivity!.latestUserRecord!.record); selectedChoiceIndex = widget .currentActivity?.practiceActivity.multipleChoice! .choiceIndex(currentRecordModel!.latestResponse!.text!); diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index d118ca684..7574290c1 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:developer'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; @@ -9,8 +8,8 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -21,7 +20,6 @@ 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 associated with a message, @@ -46,18 +44,14 @@ class MessagePracticeActivityCardState extends State { PracticeActivityRecordModel? currentCompletionRecord; bool fetchingActivity = false; - List targetTokens = []; + TargetTokensController targetTokensController = TargetTokensController(); List get practiceActivities => widget.pangeaMessageEvent.practiceActivities; - int get practiceEventIndex => practiceActivities.indexWhere( - (activity) => activity.event.eventId == currentActivity?.event.eventId, - ); - - /// TODO - @ggurdin - how can we start our processes (saving results and getting an activity) - /// immediately after a correct choice but wait to display until x milliseconds after the choice is made AND - /// we've received the new activity? + // Used to show an animation when the user completes an activity + // while simultaneously fetching a new activity and not showing the loading spinner + // until the appropriate time has passed to 'savor the joy' Duration appropriateTimeForJoy = const Duration(milliseconds: 500); bool savoringTheJoy = false; Timer? joyTimer; @@ -68,150 +62,100 @@ class MessagePracticeActivityCardState extends State { initialize(); } - void updateFetchingActivity(bool value) { + @override + void dispose() { + joyTimer?.cancel(); + super.dispose(); + } + + void _updateFetchingActivity(bool value) { if (fetchingActivity == value) return; setState(() => fetchingActivity = value); } - /// Get an activity to display. - /// Show an uncompleted activity if there is one. + /// Set target tokens. + /// Get an existing activity if there is one. /// If not, get a new activity from the server. Future initialize() async { - targetTokens = await getTargetTokens(); - - currentActivity = _fetchExistingActivity() ?? await _fetchNewActivity(); + currentActivity = + _fetchExistingIncompleteActivity() ?? await _fetchNewActivity(); currentActivity == null ? widget.overlayController.exitPracticeFlow() : widget.overlayController - .onNewActivity(currentActivity!.practiceActivity); + .setSelectedSpan(currentActivity!.practiceActivity); } - // TODO - do more of a check for whether we have an appropropriate activity // if the user did the activity before but awhile ago and we don't have any // more target tokens, maybe we should give them the same activity again - PracticeActivityEvent? _fetchExistingActivity() { - final List incompleteActivities = - practiceActivities.where((element) => !element.isComplete).toList(); - - final PracticeActivityEvent? existingActivity = - incompleteActivities.isNotEmpty ? incompleteActivities.first : null; - - return existingActivity != null && - existingActivity.practiceActivity != - currentActivity?.practiceActivity - ? existingActivity - : null; - } - - Future _fetchNewActivity() async { - updateFetchingActivity(true); - - if (targetTokens.isEmpty || - !pangeaController.languageController.languagesSet) { - debugger(when: kDebugMode); - updateFetchingActivity(false); + PracticeActivityEvent? _fetchExistingIncompleteActivity() { + if (practiceActivities.isEmpty) { return null; } - final ourNewActivity = - await pangeaController.practiceGenerationController.getPracticeActivity( - MessageActivityRequest( - userL1: pangeaController.languageController.userL1!.langCode, - userL2: pangeaController.languageController.userL2!.langCode, - messageText: representation!.text, - tokensWithXP: targetTokens, - messageId: widget.pangeaMessageEvent.eventId, - ), - widget.pangeaMessageEvent, - ); + final List incompleteActivities = + practiceActivities.where((element) => !element.isComplete).toList(); - /// Removes the target tokens of the new activity from the target tokens list. - /// This avoids getting activities for the same token again, at least - /// until the user exists the toolbar and re-enters it. By then, the - /// analytics stream will have updated and the user will be able to get - /// activity data for previously targeted tokens. This should then exclude - /// the tokens that were targeted in previous activities based on xp and lastUsed. - if (ourNewActivity?.practiceActivity.relevantSpanDisplayDetails != null) { - targetTokens.removeWhere((token) { - final RelevantSpanDisplayDetails span = - ourNewActivity!.practiceActivity.relevantSpanDisplayDetails!; - return token.token.text.offset >= span.offset && - token.token.text.offset + token.token.text.length <= - span.offset + span.length; - }); - } - - updateFetchingActivity(false); - - return ourNewActivity; + // TODO - maybe check the user's xp for the tgtConstructs and decide if its relevant for them + // however, maybe we'd like to go ahead and give them the activity to get some data on our xp? + return incompleteActivities.firstOrNull; } - /// From the tokens in the message, do a preliminary filtering of which to target - /// Then get the construct uses for those tokens - Future> getTargetTokens() async { - if (!mounted) { - ErrorHandler.logError( - m: 'getTargetTokens called when not mounted', - s: StackTrace.current, + Future _fetchNewActivity() async { + try { + debugPrint('Fetching new activity'); + + _updateFetchingActivity(true); + + // target tokens can be empty if activities have been completed for each + // it's set on initialization and then removed when each activity is completed + if (!pangeaController.languageController.languagesSet) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + if (!mounted) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + final PracticeActivityEvent? ourNewActivity = await pangeaController + .practiceGenerationController + .getPracticeActivity( + MessageActivityRequest( + userL1: pangeaController.languageController.userL1!.langCode, + userL2: pangeaController.languageController.userL2!.langCode, + messageText: representation!.text, + tokensWithXP: await targetTokensController.targetTokens( + context, + widget.pangeaMessageEvent, + ), + messageId: widget.pangeaMessageEvent.eventId, + existingActivities: practiceActivities + .map((activity) => activity.activityRequestMetaData) + .toList(), + ), + widget.pangeaMessageEvent, ); - return []; - } - // we're just going to set this once per session - // we remove the target tokens when we get a new activity - if (targetTokens.isNotEmpty) return targetTokens; + _updateFetchingActivity(false); - if (representation == null) { + return ourNewActivity; + } catch (e, s) { debugger(when: kDebugMode); - return []; + ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to get new activity', + data: { + 'activity': currentActivity, + 'record': currentCompletionRecord, + }, + ); + return null; } - final tokens = await representation?.tokensGlobal(context); - - if (tokens == null || tokens.isEmpty) { - debugger(when: kDebugMode); - return []; - } - - var constructUses = - MatrixState.pangeaController.analytics.analyticsStream.value; - - if (constructUses == null || constructUses.isEmpty) { - constructUses = []; - //@gurdin - this is happening for me with a brand-new user. however, in this case, constructUses should be empty list - debugger(when: kDebugMode); - } - - final ConstructListModel constructList = ConstructListModel( - uses: constructUses, - type: null, - ); - - final List tokenCounts = []; - - // TODO - add morph constructs to this list as well - for (int i = 0; i < tokens.length; i++) { - //don't bother with tokens that we don't save to vocab - if (!tokens[i].lemma.saveVocab) { - continue; - } - - tokenCounts.add(tokens[i].emptyTokenWithXP); - - for (final construct in tokenCounts.last.constructs) { - final constructUseModel = constructList.getConstructUses( - construct.id.lemma, - construct.id.type, - ); - if (constructUseModel != null) { - construct.xp = constructUseModel.points; - } - } - } - - tokenCounts.sort((a, b) => a.xp.compareTo(b.xp)); - - return tokenCounts; } void setCompletionRecord(PracticeActivityRecordModel? recordModel) { @@ -219,7 +163,7 @@ class MessagePracticeActivityCardState extends State { } /// future that simply waits for the appropriate time to savor the joy - Future savorTheJoy() async { + Future _savorTheJoy() async { joyTimer?.cancel(); if (savoringTheJoy) return; savoringTheJoy = true; @@ -229,10 +173,10 @@ class MessagePracticeActivityCardState extends State { }); } - /// Sends the current record model and activity to the server. - /// If either the currentRecordModel or currentActivity is null, the method returns early. - /// If the currentActivity is the last activity, the method sets the appropriate flag to true. - /// If the currentActivity is not the last activity, the method fetches a new activity. + /// Called when the user finishes an activity. + /// Saves the completion record and sends it to the server. + /// Fetches a new activity if there are any left to complete. + /// Exits the practice flow if there are no more activities. void onActivityFinish() async { try { if (currentCompletionRecord == null || currentActivity == null) { @@ -241,45 +185,63 @@ class MessagePracticeActivityCardState extends State { } // start joy timer - savorTheJoy(); + _savorTheJoy(); - // if this is the last activity, set the flag to true - // so we can give them some kudos - if (widget.overlayController.activitiesLeftToComplete == 1) { - widget.overlayController.finishedActivitiesThisSession = true; - } - - final Event? event = await MatrixState - .pangeaController.activityRecordController - .send(currentCompletionRecord!, currentActivity!); - - MatrixState.pangeaController.myAnalytics.setState( - AnalyticsStream( - eventId: widget.pangeaMessageEvent.eventId, - eventType: PangeaEventTypes.activityRecord, - roomId: event!.room.id, - practiceActivity: currentActivity!, - recordModel: currentCompletionRecord!, + final uses = currentCompletionRecord!.uses( + currentActivity!.practiceActivity, + ConstructUseMetaData( + roomId: widget.pangeaMessageEvent.room.id, + timeStamp: DateTime.now(), ), ); - if (!widget.overlayController.finishedActivitiesThisSession) { - currentActivity = await _fetchNewActivity(); + // update the target tokens with the new construct uses + targetTokensController.updateTokensWithConstructs( + uses, + context, + widget.pangeaMessageEvent, + ); - currentActivity == null - ? widget.overlayController.exitPracticeFlow() - : widget.overlayController - .onNewActivity(currentActivity!.practiceActivity); - } else { - updateFetchingActivity(false); - widget.overlayController.setState(() {}); - } + MatrixState.pangeaController.myAnalytics.setState( + AnalyticsStream( + // note - this maybe should be the activity event id + eventId: widget.pangeaMessageEvent.eventId, + roomId: widget.pangeaMessageEvent.room.id, + constructs: uses, + ), + ); + + // save the record without awaiting to avoid blocking the UI + // send a copy of the activity record to make sure its not overwritten by + // the new activity + MatrixState.pangeaController.activityRecordController + .send(currentCompletionRecord!, currentActivity!) + .catchError( + (e, s) => ErrorHandler.logError( + e: e, + s: s, + m: 'Failed to save record', + data: { + 'record': currentCompletionRecord?.toJson(), + 'activity': currentActivity?.practiceActivity.toJson(), + }, + ), + ); + + widget.overlayController.onActivityFinish(); + + currentActivity = await _fetchNewActivity(); + + currentActivity == null + ? widget.overlayController.exitPracticeFlow() + : widget.overlayController + .setSelectedSpan(currentActivity!.practiceActivity); } catch (e, s) { debugger(when: kDebugMode); ErrorHandler.logError( e: e, s: s, - m: 'Failed to send record for activity', + m: 'Failed to get new activity', data: { 'activity': currentActivity, 'record': currentCompletionRecord, @@ -340,6 +302,9 @@ class MessagePracticeActivityCardState extends State { @override Widget build(BuildContext context) { + debugPrint( + 'Building practice activity card with ${widget.overlayController.activitiesLeftToComplete} activities left to complete', + ); if (userMessage != null) { return Center( child: Container( @@ -385,3 +350,92 @@ class MessagePracticeActivityCardState extends State { ); } } + +/// Seperated out the target tokens from the practice activity card +/// in order to control the state of the target tokens +class TargetTokensController { + List? _targetTokens; + + TargetTokensController(); + + /// From the tokens in the message, do a preliminary filtering of which to target + /// Then get the construct uses for those tokens + Future> targetTokens( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (_targetTokens != null) { + return _targetTokens!; + } + + _targetTokens = await _initialize(context, pangeaMessageEvent); + + await updateTokensWithConstructs( + MatrixState.pangeaController.analytics.analyticsStream.value ?? [], + context, + pangeaMessageEvent, + ); + + return _targetTokens!; + } + + Future> _initialize( + BuildContext context, + PangeaMessageEvent pangeaMessageEvent, + ) async { + if (!context.mounted) { + ErrorHandler.logError( + m: 'getTargetTokens called when not mounted', + s: StackTrace.current, + ); + return _targetTokens = []; + } + + final tokens = await pangeaMessageEvent + .representationByLanguage(pangeaMessageEvent.messageDisplayLangCode) + ?.tokensGlobal(context); + + if (tokens == null || tokens.isEmpty) { + debugger(when: kDebugMode); + return _targetTokens = []; + } + + _targetTokens = []; + for (int i = 0; i < tokens.length; i++) { + //don't bother with tokens that we don't save to vocab + if (!tokens[i].lemma.saveVocab) { + continue; + } + + _targetTokens!.add(tokens[i].emptyTokenWithXP); + } + + return _targetTokens!; + } + + Future updateTokensWithConstructs( + List constructUses, + context, + pangeaMessageEvent, + ) async { + final ConstructListModel constructList = ConstructListModel( + uses: constructUses, + type: null, + ); + + _targetTokens ??= await _initialize(context, pangeaMessageEvent); + + for (final token in _targetTokens!) { + for (final construct in token.constructs) { + final constructUseModel = constructList.getConstructUses( + construct.id.lemma, + construct.id.type, + ); + if (constructUseModel != null) { + construct.xp = constructUseModel.points; + construct.lastUsed = constructUseModel.lastUsed; + } + } + } + } +}