diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index bf142b185..7c97c5e08 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -639,6 +639,7 @@ class ChatController extends State pangeaController.myAnalytics.setState( data: { 'eventID': msgEventId, + 'eventType': EventTypes.Message, 'roomID': room.id, 'originalSent': originalSent, 'tokensSent': tokensSent, diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index ac4efb668..431cf69ee 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/local.key.dart'; import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; @@ -25,16 +27,13 @@ class GetAnalyticsController { Client get client => _pangeaController.matrixState.client; - // A local cache of eventIds and construct uses for messages sent since the last update + /// A local cache of eventIds and construct uses for messages sent since the last update Map> get messagesSinceUpdate { try { final dynamic locallySaved = _pangeaController.pStoreService.read( PLocalKey.messagesSinceUpdate, ); - if (locallySaved == null) { - _pangeaController.myAnalytics.setMessagesSinceUpdate({}); - return {}; - } + if (locallySaved == null) return {}; try { // try to get the local cache of messages and format them as OneConstructUses final Map> cache = @@ -47,7 +46,7 @@ class GetAnalyticsController { return formattedCache; } catch (err) { // if something goes wrong while trying to format the local data, clear it - _pangeaController.myAnalytics.setMessagesSinceUpdate({}); + _pangeaController.myAnalytics.clearMessagesSinceUpdate(); return {}; } } catch (exception, stackTrace) { @@ -70,13 +69,24 @@ class GetAnalyticsController { debugPrint("getting constructs"); await client.roomsLoading; - // first, try to get a cached list of all uses, if it exists and is valid - final DateTime? lastUpdated = await myAnalyticsLastUpdated(); + // don't try to get constructs until last updated time has been loaded + await _pangeaController.myAnalytics.lastUpdatedCompleter.future; + + // if forcing a refreshing, clear the cache + if (forceUpdate) clearCache(); + + // get the last time the user updated their analytics for their current l2 + // then try to get local cache of construct uses. lastUpdate time is used to + // determine if cached data is still valid. + final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated ?? + await myAnalyticsLastUpdated(); + final List? local = getConstructsLocal( constructType: constructType, lastUpdated: lastUpdated, ); - if (local != null && !forceUpdate) { + + if (local != null) { debugPrint("returning local constructs"); return local; } @@ -111,7 +121,12 @@ class GetAnalyticsController { /// Get the last time the user updated their analytics for their current l2 Future myAnalyticsLastUpdated() async { - if (l2Code == null) return null; + // this function gets called soon after login, so first + // make sure that the user's l2 is loaded, if the user has set their l2 + if (client.userID != null && l2Code == null) { + await _pangeaController.matrixState.client.waitForAccountData(); + if (l2Code == null) return null; + } final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); if (analyticsRoom == null) return null; final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( @@ -120,6 +135,29 @@ class GetAnalyticsController { return lastUpdated; } + /// Get all the construct analytics events for the logged in user + Future> allMyConstructs() async { + if (l2Code == null) return []; + final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); + if (analyticsRoom == null) return []; + return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? []; + } + + /// Filter out constructs that are not relevant to the user, specifically those from + /// rooms in which the user is a teacher and those that are interative translation span constructs + Future> filterConstructs({ + required List unfilteredConstructs, + }) async { + return unfilteredConstructs + .where( + (use) => + use.lemma != "Try interactive translation" && + use.lemma != "itStart" || + use.lemma != MatchRuleIds.interactiveTranslation, + ) + .toList(); + } + /// Get the cached construct uses for the current user, if it exists List? getConstructsLocal({ DateTime? lastUpdated, @@ -140,28 +178,6 @@ class GetAnalyticsController { return null; } - /// Get all the construct analytics events for the logged in user - Future> allMyConstructs() async { - if (l2Code == null) return []; - final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); - if (analyticsRoom == null) return []; - return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? []; - } - - /// Filter out constructs that are not relevant to the user, specifically those from - /// rooms in which the user is a teacher and those that are interative translation span constructs - Future> filterConstructs({ - required List unfilteredConstructs, - }) async { - final List adminSpaceRooms = await client.teacherRoomIds; - return unfilteredConstructs.where((use) { - if (adminSpaceRooms.contains(use.chatId)) return false; - return use.lemma != "Try interactive translation" && - use.lemma != "itStart" || - use.lemma != MatchRuleIds.interactiveTranslation; - }).toList(); - } - /// Cache the construct uses for the current user void cacheConstructs({ required List uses, @@ -175,6 +191,11 @@ class GetAnalyticsController { ); _cache.add(entry); } + + /// Clear all cached analytics data. + void clearCache() { + _cache.clear(); + } } class AnalyticsCacheEntry { diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 1883e96dc..29cbef9ea 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -156,14 +156,6 @@ class AnalyticsController extends BaseController { ?.cast(); final List allConstructs = roomEvents ?? []; - final List adminSpaceRooms = - await _pangeaController.matrixState.client.teacherRoomIds; - for (final construct in allConstructs) { - construct.content.uses.removeWhere( - (use) => adminSpaceRooms.contains(use.chatId), - ); - } - return allConstructs .where((construct) => construct.content.uses.isNotEmpty) .toList(); diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 4389f297f..11840836b 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:developer'; import 'package:fluffychat/pangea/constants/local.key.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; @@ -7,10 +6,10 @@ import 'package:fluffychat/pangea/controllers/base_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.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/pangea_message_event.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/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'; @@ -29,9 +28,16 @@ class MyAnalyticsController extends BaseController { String? get userL2 => _pangeaController.languageController.activeL2Code(); + /// the last time that matrix analytics events were updated for the user's current l2 + DateTime? lastUpdated; + + /// Last updated completer. Used to wait for the last + /// updated time to be set before setting analytics data. + Completer lastUpdatedCompleter = Completer(); + /// the max number of messages that will be cached before /// an automatic update is triggered - final int _maxMessagesCached = 10; + final int _maxMessagesCached = 1; /// the number of minutes before an automatic update is triggered final int _minutesBeforeUpdate = 5; @@ -44,8 +50,9 @@ class MyAnalyticsController extends BaseController { // Wait for the next sync in the stream to ensure that the pangea controller // is fully initialized. It will throw an error if it is not. - _pangeaController.matrixState.client.onSync.stream.first - .then((_) => _refreshAnalyticsIfOutdated()); + _pangeaController.matrixState.client.onSync.stream.first.then((_) { + _refreshAnalyticsIfOutdated(); + }); // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user @@ -55,23 +62,31 @@ class MyAnalyticsController extends BaseController { } /// If analytics haven't been updated in the last day, update them - Future _refreshAnalyticsIfOutdated() async { - /// wait for the initial sync to finish, so the - /// timeline data from analytics rooms is accurate - if (_client.prevBatch == null) { - await _client.onSync.stream.first; + Future _refreshAnalyticsIfOutdated() async { + // don't set anything is the user is not logged in + if (_pangeaController.matrixState.client.userID == null) return; + try { + // if lastUpdated hasn't been set yet, set it + lastUpdated ??= + await _pangeaController.analytics.myAnalyticsLastUpdated(); + } catch (err, s) { + ErrorHandler.logError( + s: s, + e: err, + m: "Failed to get last updated time for analytics", + ); + } finally { + // if this is the initial load, complete the lastUpdatedCompleter + if (!lastUpdatedCompleter.isCompleted) { + lastUpdatedCompleter.complete(lastUpdated); + } } - DateTime? lastUpdated = - await _pangeaController.analytics.myAnalyticsLastUpdated(); final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate); - if (lastUpdated?.isBefore(yesterday) ?? true) { debugPrint("analytics out-of-date, updating"); await updateAnalytics(); - lastUpdated = await _pangeaController.analytics.myAnalyticsLastUpdated(); } - return lastUpdated; } /// Given the data from a newly sent message, format and cache @@ -88,9 +103,12 @@ class MyAnalyticsController extends BaseController { // extract the relevant data about this message final String? eventID = data['eventID']; final String? roomID = data['roomID']; + final String? eventType = data['eventType']; final PangeaRepresentation? originalSent = data['originalSent']; final PangeaMessageTokens? tokensSent = data['tokensSent']; final ChoreoRecord? choreo = data['choreo']; + final PracticeActivityEvent? practiceActivity = data['practiceActivity']; + final PracticeActivityRecordModel? recordModel = data['recordModel']; if (roomID == null || eventID == null) return; @@ -101,24 +119,38 @@ class MyAnalyticsController extends BaseController { timeStamp: DateTime.now(), ); - final grammarConstructs = choreo?.grammarConstructUses(metadata: metadata); - final itConstructs = choreo?.itStepsToConstructUses(metadata: metadata); - final vocabUses = tokensSent != null - ? originalSent?.vocabUses( - choreo: choreo, - tokens: tokensSent.tokens, - metadata: metadata, - ) - : null; - final List constructs = [ - ...(grammarConstructs ?? []), - ...(itConstructs ?? []), - ...(vocabUses ?? []), - ]; - addMessageSinceUpdate( - eventID, - constructs, - ); + final List constructs = []; + + if (eventType == EventTypes.Message) { + final grammarConstructs = + choreo?.grammarConstructUses(metadata: metadata); + final itConstructs = choreo?.itStepsToConstructUses(metadata: metadata); + final vocabUses = tokensSent != null + ? originalSent?.vocabUses( + choreo: choreo, + tokens: tokensSent.tokens, + metadata: metadata, + ) + : null; + constructs.addAll([ + ...(grammarConstructs ?? []), + ...(itConstructs ?? []), + ...(vocabUses ?? []), + ]); + } + + if (eventType == PangeaEventTypes.activityRecord && + practiceActivity != null) { + final activityConstructs = recordModel?.uses( + practiceActivity, + metadata: metadata, + ); + constructs.addAll(activityConstructs ?? []); + } + + _pangeaController.analytics + .filterConstructs(unfilteredConstructs: constructs) + .then((filtered) => addMessageSinceUpdate(eventID, filtered)); } /// Add a list of construct uses for a new message to the local @@ -129,10 +161,9 @@ class MyAnalyticsController extends BaseController { ) { try { final currentCache = _pangeaController.analytics.messagesSinceUpdate; - if (!currentCache.containsKey(eventID)) { - currentCache[eventID] = constructs; - setMessagesSinceUpdate(currentCache); - } + constructs.addAll(currentCache[eventID] ?? []); + currentCache[eventID] = constructs; + setMessagesSinceUpdate(currentCache); // if the cached has reached if max-length, update analytics if (_pangeaController.analytics.messagesSinceUpdate.length > @@ -151,7 +182,7 @@ class MyAnalyticsController extends BaseController { /// Clears the local cache of recently sent constructs. Called before updating analytics void clearMessagesSinceUpdate() { - setMessagesSinceUpdate({}); + _pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate); } /// Save the local cache of recently sent constructs to the local storage @@ -168,8 +199,18 @@ class MyAnalyticsController extends BaseController { analyticsUpdateStream.add(null); } + /// Prevent concurrent updates to analytics Completer? _updateCompleter; + + /// Updates learning analytics. + /// + /// This method is responsible for updating the analytics. It first checks if an update is already in progress + /// by checking the completion status of the [_updateCompleter]. If an update is already in progress, it waits + /// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and + /// proceeds with the update process. If the update is successful, it clears any messages that were received + /// since the last update and notifies the [analyticsUpdateStream]. Future updateAnalytics() async { + if (_pangeaController.matrixState.client.userID == null) return; if (!(_updateCompleter?.isCompleted ?? true)) { await _updateCompleter!.future; return; @@ -178,6 +219,7 @@ class MyAnalyticsController extends BaseController { try { await _updateAnalytics(); clearMessagesSinceUpdate(); + lastUpdated = DateTime.now(); analyticsUpdateStream.add(null); } catch (err, s) { ErrorHandler.logError( @@ -191,119 +233,31 @@ class MyAnalyticsController extends BaseController { } } - /// top level analytics sending function. Gather recent messages and activity records, - /// convert them into the correct formats, and send them to the analytics room + /// Updates the analytics by sending cached analytics data to the analytics room. + /// The analytics room is determined based on the user's current target language. Future _updateAnalytics() async { + // if there's no cached construct data, there's nothing to send + if (_pangeaController.analytics.messagesSinceUpdate.isEmpty) return; + // if missing important info, don't send analytics. Could happen if user just signed up. if (userL2 == null || _client.userID == null) return; // analytics room for the user and current target language final Room? analyticsRoom = await _client.getMyAnalyticsRoom(userL2!); - // get the last time analytics were updated for this room - final DateTime? l2AnalyticsLastUpdated = - await analyticsRoom?.analyticsLastUpdated( - _client.userID!, + // and send cached analytics data to the room + await analyticsRoom?.sendConstructsEvent( + _pangeaController.analytics.messagesSinceUpdate.values + .expand((e) => e) + .toList(), ); + } - // all chats in which user is a student - final List chats = _client.rooms - .where((room) => !room.isSpace && !room.isAnalyticsRoom) - .toList(); - - // get the recent message events and activity records for each chat - final List>> recentMsgFutures = []; - final List>> recentActivityFutures = []; - for (final Room chat in chats) { - recentMsgFutures.add( - chat.getEventsBySender( - type: EventTypes.Message, - sender: _client.userID!, - since: l2AnalyticsLastUpdated, - ), - ); - recentActivityFutures.add( - chat.getEventsBySender( - type: PangeaEventTypes.activityRecord, - sender: _client.userID!, - since: l2AnalyticsLastUpdated, - ), - ); - } - final List> recentMsgs = - (await Future.wait(recentMsgFutures)).toList(); - final List recentActivityRecords = - (await Future.wait(recentActivityFutures)) - .expand((e) => e) - .map((event) => PracticeActivityRecordEvent(event: event)) - .toList(); - - // get the timelines for each chat - final List> timelineFutures = []; - for (final chat in chats) { - timelineFutures.add(chat.getTimeline()); - } - final List timelines = await Future.wait(timelineFutures); - final Map timelineMap = - Map.fromIterables(chats.map((e) => e.id), timelines); - - //convert into PangeaMessageEvents - final List> recentPangeaMessageEvents = []; - for (final (index, eventList) in recentMsgs.indexed) { - recentPangeaMessageEvents.add( - eventList - .map( - (event) => PangeaMessageEvent( - event: event, - timeline: timelines[index], - ownMessage: true, - ), - ) - .toList(), - ); - } - - final List allRecentMessages = - recentPangeaMessageEvents.expand((e) => e).toList(); - - // get constructs for messages - final List recentConstructUses = []; - for (final PangeaMessageEvent message in allRecentMessages) { - recentConstructUses.addAll(message.allConstructUses); - } - - // get constructs for practice activities - final List>> constructFutures = []; - for (final PracticeActivityRecordEvent activity in recentActivityRecords) { - final Timeline? timeline = timelineMap[activity.event.roomId!]; - if (timeline == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "PracticeActivityRecordEvent has null timeline", - data: activity.event.toJson(), - ); - continue; - } - constructFutures.add(activity.uses(timeline)); - } - final List> constructLists = - await Future.wait(constructFutures); - - recentConstructUses.addAll(constructLists.expand((e) => e)); - - //TODO - confirm that this is the correct construct content - // debugger( - // when: kDebugMode, - // ); - // ; debugger( - // when: kDebugMode && - // (allRecentMessages.isNotEmpty || recentActivityRecords.isNotEmpty), - // ); - - if (recentConstructUses.isNotEmpty || l2AnalyticsLastUpdated == null) { - await analyticsRoom?.sendConstructsEvent( - recentConstructUses, - ); - } + /// Reset analytics last updated time to null. + void clearCache() { + _updateTimer?.cancel(); + lastUpdated = null; + lastUpdatedCompleter = Completer(); + _refreshAnalyticsIfOutdated(); } } diff --git a/lib/pangea/controllers/user_controller.dart b/lib/pangea/controllers/user_controller.dart index bf8ce3c11..38645cfcd 100644 --- a/lib/pangea/controllers/user_controller.dart +++ b/lib/pangea/controllers/user_controller.dart @@ -71,9 +71,12 @@ class UserController extends BaseController { } /// Updates the user's profile with the given [update] function and saves it. - void updateProfile(Profile Function(Profile) update) { + Future updateProfile( + Profile Function(Profile) update, { + waitForDataInSync = false, + }) async { final Profile updatedProfile = update(profile); - updatedProfile.saveProfileData(); + await updatedProfile.saveProfileData(waitForDataInSync: waitForDataInSync); } /// Creates a new profile for the user with the given date of birth. diff --git a/lib/pangea/extensions/client_extension/client_analytics_extension.dart b/lib/pangea/extensions/client_extension/client_analytics_extension.dart index 82f1b621b..64691f98b 100644 --- a/lib/pangea/extensions/client_extension/client_analytics_extension.dart +++ b/lib/pangea/extensions/client_extension/client_analytics_extension.dart @@ -153,16 +153,4 @@ extension AnalyticsClientExtension on Client { _joinAnalyticsRoomsInAllSpaces(); }); } - - Future> _allAnalyticsRoomsLastUpdated() async { - // get the last updated time for each analytics room - final Map lastUpdatedMap = {}; - for (final analyticsRoom in allMyAnalyticsRooms) { - final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( - userID!, - ); - lastUpdatedMap[analyticsRoom.id] = lastUpdated; - } - return lastUpdatedMap; - } } diff --git a/lib/pangea/extensions/client_extension/client_extension.dart b/lib/pangea/extensions/client_extension/client_extension.dart index 7af50d501..0971ab04d 100644 --- a/lib/pangea/extensions/client_extension/client_extension.dart +++ b/lib/pangea/extensions/client_extension/client_extension.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; @@ -39,15 +38,10 @@ extension PangeaClient on Client { /// and set up those rooms to be joined by other users. void migrateAnalyticsRooms() => _migrateAnalyticsRooms(); - Future> allAnalyticsRoomsLastUpdated() async => - await _allAnalyticsRoomsLastUpdated(); - // spaces List get spacesImTeaching => _spacesImTeaching; - Future> get chatsImAStudentIn async => await _chatsImAStudentIn; - List get spacesImAStudentIn => _spacesImStudyingIn; List get spacesImIn => _spacesImIn; @@ -56,8 +50,6 @@ extension PangeaClient on Client { // general_info - Future> get teacherRoomIds async => await _teacherRoomIds; - Future> get myTeachers async => await _myTeachers; Future getReportsDM(User teacher, Room space) async => diff --git a/lib/pangea/extensions/client_extension/general_info_extension.dart b/lib/pangea/extensions/client_extension/general_info_extension.dart index ca5df40cc..a32c43d61 100644 --- a/lib/pangea/extensions/client_extension/general_info_extension.dart +++ b/lib/pangea/extensions/client_extension/general_info_extension.dart @@ -1,16 +1,6 @@ part of "client_extension.dart"; extension GeneralInfoClientExtension on Client { - Future> get _teacherRoomIds async { - final List adminRoomIds = []; - for (final Room adminSpace in (await _spacesImTeaching)) { - adminRoomIds.add(adminSpace.id); - final List adminSpaceRooms = adminSpace.allSpaceChildRoomIds; - adminRoomIds.addAll(adminSpaceRooms); - } - return adminRoomIds; - } - Future> get _myTeachers async { final List teachers = []; for (final classRoom in spacesImIn) { diff --git a/lib/pangea/extensions/client_extension/space_extension.dart b/lib/pangea/extensions/client_extension/space_extension.dart index 89a09a7cd..8d9182d85 100644 --- a/lib/pangea/extensions/client_extension/space_extension.dart +++ b/lib/pangea/extensions/client_extension/space_extension.dart @@ -4,18 +4,6 @@ extension SpaceClientExtension on Client { List get _spacesImTeaching => rooms.where((e) => e.isSpace && e.isRoomAdmin).toList(); - Future> get _chatsImAStudentIn async { - final List nowteacherRoomIds = await teacherRoomIds; - return rooms - .where( - (r) => - !r.isSpace && - !r.isAnalyticsRoom && - !nowteacherRoomIds.contains(r.id), - ) - .toList(); - } - List get _spacesImStudyingIn => rooms.where((e) => e.isSpace && !e.isRoomAdmin).toList(); diff --git a/lib/pangea/extensions/pangea_room_extension/events_extension.dart b/lib/pangea/extensions/pangea_room_extension/events_extension.dart index 3fe14750d..d8e2545ff 100644 --- a/lib/pangea/extensions/pangea_room_extension/events_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/events_extension.dart @@ -346,66 +346,51 @@ extension EventsRoomExtension on Room { // } // } - // fetch event of a certain type by a certain sender - // since a certain time or up to a certain amount - Future> getEventsBySender({ - required String type, - required String sender, - DateTime? since, + /// Get a list of events in the room that are of type [PangeaEventTypes.construct] + /// and have the sender as [userID]. If [count] is provided, the function will + /// return at most [count] events. + Future> getRoomAnalyticsEvents({ + String? userID, int? count, }) async { - try { - int numberOfSearches = 0; - final Timeline timeline = await getTimeline(); - - List relevantEvents() => timeline.events - .where((event) => event.senderId == sender && event.type == type) - .toList(); - - bool reachedEnd() { - if (since != null) { - return relevantEvents().any( - (event) => event.originServerTs.isBefore(since), - ); - } - if (count != null) { - return relevantEvents().length >= count; - } - return false; - } - - while (timeline.canRequestHistory && numberOfSearches < 10) { - await timeline.requestHistory(historyCount: 100); - numberOfSearches += 1; - if (!timeline.canRequestHistory) break; - if (reachedEnd()) break; - } - - final List fetchedEvents = timeline.events - .where((event) => event.senderId == sender && event.type == type) - .toList(); - - if (since != null) { - fetchedEvents.removeWhere( - (event) => event.originServerTs.isBefore(since), - ); - } - - final List events = []; - for (Event event in fetchedEvents) { - if (event.relationshipType == RelationshipTypes.edit) continue; - if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) { - event = event.getDisplayEvent(timeline); - } - events.add(event); - } + userID ??= client.userID; + if (userID == null) return []; + GetRoomEventsResponse resp = await client.getRoomEvents( + id, + Direction.b, + limit: count ?? 100, + filter: jsonEncode( + StateFilter( + types: [ + PangeaEventTypes.construct, + ], + senders: [userID], + ), + ), + ); - return events; - } catch (err, s) { - if (kDebugMode) rethrow; - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - return []; + int numSearches = 0; + while (numSearches < 10 && resp.end != null) { + if (count != null && resp.chunk.length <= count) break; + final nextResp = await client.getRoomEvents( + id, + Direction.b, + limit: count ?? 100, + filter: jsonEncode( + StateFilter( + types: [ + PangeaEventTypes.construct, + ], + senders: [userID], + ), + ), + from: resp.end, + ); + nextResp.chunk.addAll(resp.chunk); + resp = nextResp; + numSearches += 1; } + + return resp.chunk.map((e) => Event.fromMatrixEvent(e, this)).toList(); } } diff --git a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart index 8bfb09c8a..bf9e10953 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -90,6 +90,16 @@ extension PangeaRoom on Room { bool isMadeForLang(String langCode) => _isMadeForLang(langCode); + /// Sends construct events to the server. + /// + /// The [uses] parameter is a list of [OneConstructUse] objects representing the + /// constructs to be sent. To prevent hitting the maximum event size, the events + /// are chunked into smaller lists. Each chunk is sent as a separate event. + Future sendConstructsEvent( + List uses, + ) async => + await _sendConstructsEvent(uses); + // children_and_parents List get joinedChildren => _joinedChildren; diff --git a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart index c09e01a65..73371b080 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -140,33 +140,17 @@ extension AnalyticsRoomExtension on Room { ); } - Future _getLastAnalyticsEvent( - String userId, - ) async { - final List events = await getEventsBySender( - type: PangeaEventTypes.construct, - sender: userId, - count: 10, - ); - if (events.isEmpty) return null; - final Event event = events.first; - return ConstructAnalyticsEvent(event: event); - } - Future _analyticsLastUpdated(String userId) async { - final lastEvent = await _getLastAnalyticsEvent(userId); - return lastEvent?.event.originServerTs; + final List events = await getRoomAnalyticsEvents(count: 1); + if (events.isEmpty) return null; + return events.first.originServerTs; } Future?> _getAnalyticsEvents({ required String userId, DateTime? since, }) async { - final List events = await getEventsBySender( - type: PangeaEventTypes.construct, - sender: userId, - since: since, - ); + final events = await getRoomAnalyticsEvents(); final List analyticsEvents = []; for (final Event event in events) { analyticsEvents.add(ConstructAnalyticsEvent(event: event)); @@ -192,7 +176,7 @@ extension AnalyticsRoomExtension on Room { /// The [uses] parameter is a list of [OneConstructUse] objects representing the /// constructs to be sent. To prevent hitting the maximum event size, the events /// are chunked into smaller lists. Each chunk is sent as a separate event. - Future sendConstructsEvent( + Future _sendConstructsEvent( List uses, ) async { // It's possible that the user has no info to send yet, but to prevent trying diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_record_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_record_event.dart index d0f11da3c..8378d9f88 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_record_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_record_event.dart @@ -1,12 +1,5 @@ -import 'dart:developer'; - import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import '../constants/pangea_event_types.dart'; @@ -28,64 +21,4 @@ class PracticeActivityRecordEvent { _content ??= event.getPangeaContent(); return _content!; } - - Future> uses(Timeline timeline) async { - try { - final String? parent = event.relationshipEventId; - if (parent == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "PracticeActivityRecordEvent has null event.relationshipEventId", - data: event.toJson(), - ); - return []; - } - - final Event? practiceEvent = - await timeline.getEventById(event.relationshipEventId!); - - if (practiceEvent == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "PracticeActivityRecordEvent has null practiceActivityEvent with id $parent", - data: event.toJson(), - ); - return []; - } - - final PracticeActivityEvent practiceActivity = PracticeActivityEvent( - event: practiceEvent, - timeline: timeline, - ); - - final List uses = []; - - final List constructIds = - practiceActivity.practiceActivity.tgtConstructs; - - for (final construct in constructIds) { - uses.add( - OneConstructUse( - lemma: construct.lemma, - constructType: construct.type, - useType: record.useType, - //TODO - find form of construct within the message - //this is related to the feature of highlighting the target construct in the message - form: construct.lemma, - metadata: ConstructUseMetaData( - roomId: event.roomId ?? practiceEvent.roomId ?? timeline.room.id, - eventId: practiceActivity.parentMessageId, - timeStamp: event.originServerTs, - ), - ), - ); - } - - return uses; - } catch (e, s) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: e, s: s, data: event.toJson()); - rethrow; - } - } } 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 0c4ea52bf..34f73e735 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 @@ -3,9 +3,14 @@ // the user might have selected multiple options before // finding the answer import 'dart:developer'; -import 'dart:typed_data'; 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; @@ -79,6 +84,56 @@ 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. + List uses( + PracticeActivityEvent practiceActivity, { + Event? event, + 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, + ), + ), + ); + } + + return uses; + } catch (e, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: e, s: s, data: event?.toJson()); + rethrow; + } + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/lib/pangea/utils/logout.dart b/lib/pangea/utils/logout.dart index aa4441e13..3d3e780ba 100644 --- a/lib/pangea/utils/logout.dart +++ b/lib/pangea/utils/logout.dart @@ -1,11 +1,9 @@ -import 'package:flutter/material.dart'; - import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async { if (await showOkCancelAlertDialog( useRootNavigator: false, @@ -20,6 +18,14 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async { return; } final matrix = Matrix.of(context); + + // before wiping out locally cached construct data, save it to the server + await MatrixState.pangeaController.myAnalytics.updateAnalytics(); + + // Reset cached analytics data + MatrixState.pangeaController.myAnalytics.clearCache(); + MatrixState.pangeaController.analytics.clearCache(); + await showFutureLoadingDialog( context: context, future: () => matrix.client.logout(), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index d485be851..3a2accad8 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -42,10 +42,14 @@ class LearningProgressIndicatorsState /// Grammar constructs model ConstructListModel? errors; + bool loading = true; + @override void initState() { super.initState(); - updateAnalyticsData(); + updateAnalyticsData().then((_) { + setState(() => loading = false); + }); // listen for changes to analytics data and update the UI _onAnalyticsUpdate = _pangeaController .myAnalytics.analyticsUpdateStream.stream @@ -77,6 +81,7 @@ class LearningProgressIndicatorsState type: ConstructTypeEnum.grammar, uses: localUses, ); + setState(() {}); return; } @@ -93,7 +98,8 @@ class LearningProgressIndicatorsState type: ConstructTypeEnum.grammar, uses: allConstructs, ); - setState(() {}); + + if (mounted) setState(() {}); } /// Get the number of points for a given progress indicator @@ -136,6 +142,10 @@ class LearningProgressIndicatorsState @override Widget build(BuildContext context) { + if (Matrix.of(context).client.userID == null) { + return const SizedBox(); + } + final levelBar = Container( height: 20, width: levelBarWidth, @@ -214,6 +224,7 @@ class LearningProgressIndicatorsState points: getProgressPoints(indicator), onTap: () {}, progressIndicator: indicator, + loading: loading, ), ) .toList(), @@ -222,9 +233,6 @@ class LearningProgressIndicatorsState ), ), Container( - // decoration: BoxDecoration( - // border: Border.all(color: Colors.green), - // ), height: 36, padding: const EdgeInsets.symmetric(horizontal: 32), child: Stack( diff --git a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart index 8fe774f1c..f9ef427e4 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart @@ -7,12 +7,14 @@ class ProgressIndicatorBadge extends StatelessWidget { final int? points; final VoidCallback onTap; final ProgressIndicatorEnum progressIndicator; + final bool loading; const ProgressIndicatorBadge({ super.key, required this.points, required this.onTap, required this.progressIndicator, + required this.loading, }); @override @@ -33,9 +35,9 @@ class ProgressIndicatorBadge extends StatelessWidget { color: progressIndicator.color(context), ), const SizedBox(width: 5), - points != null + !loading ? AnimatedCount( - count: points!, + count: points ?? 0, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 5d0b81662..6e24c9e8b 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.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'; @@ -102,6 +103,20 @@ class MessagePracticeActivityCardState extends State { }, ); return null; + }).then((event) { + // The record event is processed into construct uses for learning analytics, so if the + // event went through without error, send it to analytics to be processed + if (event != null && currentActivity != null) { + MatrixState.pangeaController.myAnalytics.setState( + data: { + 'eventID': widget.pangeaMessageEvent.eventId, + 'eventType': PangeaEventTypes.activityRecord, + 'roomID': event.room.id, + 'practiceActivity': currentActivity!, + 'recordModel': currentRecordModel!, + }, + ); + } }).whenComplete(() => setState(() => sending = false)); } diff --git a/lib/pangea/widgets/user_settings/p_language_dialog.dart b/lib/pangea/widgets/user_settings/p_language_dialog.dart index bfc85528b..5b08d3cad 100644 --- a/lib/pangea/widgets/user_settings/p_language_dialog.dart +++ b/lib/pangea/widgets/user_settings/p_language_dialog.dart @@ -91,15 +91,22 @@ Future pLanguageDialog( context: context, future: () async { try { - pangeaController.userController - .updateProfile((profile) { - profile.userSettings.sourceLanguage = - selectedSourceLanguage.langCode; - profile.userSettings.targetLanguage = - selectedTargetLanguage.langCode; - return profile; + pangeaController.userController.updateProfile( + (profile) { + profile.userSettings.sourceLanguage = + selectedSourceLanguage.langCode; + profile.userSettings.targetLanguage = + selectedTargetLanguage.langCode; + return profile; + }, + waitForDataInSync: true, + ).then((_) { + // if the profile update is successful, reset cached analytics + // data, since analytics data corresponds to the user's L2 + pangeaController.myAnalytics.clearCache(); + pangeaController.analytics.clearCache(); + Navigator.pop(context); }); - Navigator.pop(context); } catch (err, s) { debugger(when: kDebugMode); ErrorHandler.logError(e: err, s: s);