diff --git a/lib/config/routes.dart b/lib/config/routes.dart index e6ab37967..8bf162372 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -24,6 +24,7 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; +import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart'; import 'package:fluffychat/pangea/guard/p_vguard.dart'; import 'package:fluffychat/pangea/pages/analytics/student_analytics/student_analytics.dart'; import 'package:fluffychat/pangea/pages/exchange/add_exchange_to_class.dart'; @@ -171,6 +172,28 @@ abstract class AppRoutes { const StudentAnalyticsPage(), ), redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: 'messages', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const StudentAnalyticsPage( + selectedView: BarChartViewSelection.messages, + ), + ), + ), + GoRoute( + path: 'errors', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + const StudentAnalyticsPage( + selectedView: BarChartViewSelection.grammar, + ), + ), + ), + ], ), GoRoute( path: 'analytics', @@ -189,6 +212,36 @@ abstract class AppRoutes { state, const ClassAnalyticsPage(), ), + routes: [ + GoRoute( + path: 'messages', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ClassAnalyticsPage( + // when going to sub-space from within a parent space's analytics, the + // analytics list tiles do not properly update. Adding a unique key to this page is the best fix + // I can find at the moment + key: UniqueKey(), + selectedView: BarChartViewSelection.messages, + ), + ), + ), + GoRoute( + path: 'errors', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ClassAnalyticsPage( + // when going to sub-space from within a parent space's analytics, the + // analytics list tiles do not properly update. Adding a unique key to this page is the best fix + // I can find at the moment + key: UniqueKey(), + selectedView: BarChartViewSelection.grammar, + ), + ), + ), + ], ), ], ), diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 4f363ac4a..69afab2aa 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -22,7 +22,6 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/class_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/firebase_analytics.dart'; @@ -657,34 +656,8 @@ class ChatController extends State ); return; } - // ensure that analytics room exists / is created for the active langCode await room.ensureAnalyticsRoomExists(); - pangeaController.myAnalytics.handleMessage( - room, - RecentMessageRecord( - eventId: msgEventId, - chatId: room.id, - useType: useType ?? UseType.un, - time: DateTime.now(), - ), - isEdit: previousEdit != null, - ); - - if (choreo != null && - tokensSent != null && - originalSent?.langCode == - pangeaController.languageController - .activeL2Code(roomID: room.id)) { - pangeaController.myAnalytics.saveConstructsMixed( - [ - // ...choreo.toVocabUse(tokensSent.tokens, room.id, msgEventId), - ...choreo.toGrammarConstructUse(msgEventId, room.id), - ], - originalSent!.langCode, - isEdit: previousEdit != null, - ); - } }, onError: (err, stack) => ErrorHandler.logError(e: err, s: stack), ); diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index f7294e571..e9d043284 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -902,6 +902,7 @@ class ChatListController extends State if (mounted) { GoogleAnalytics.analyticsUserUpdate(client.userID); await pangeaController.subscriptionController.initialize(); + await pangeaController.myAnalytics.addEventsListener(); pangeaController.afterSyncAndFirstLoginInitialization(context); await pangeaController.inviteBotToExistingSpaces(); await pangeaController.setPangeaPushRules(); diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 58b982cab..602be9192 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -1,6 +1,5 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; -import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/utils/class_code.dart'; import 'package:fluffychat/pangea/utils/find_conversation_partner_dialog.dart'; import 'package:fluffychat/pangea/utils/logout.dart'; @@ -69,7 +68,7 @@ class ClientChooserButton extends StatelessWidget { ), ), PopupMenuItem( - enabled: matrix.client.allMyAnalyticsRooms.isNotEmpty, + enabled: matrix.client.rooms.isNotEmpty, value: SettingsAction.myAnalytics, child: Row( children: [ diff --git a/lib/pages/new_space/new_space.dart b/lib/pages/new_space/new_space.dart index 1842a1695..fcb9f136b 100644 --- a/lib/pages/new_space/new_space.dart +++ b/lib/pages/new_space/new_space.dart @@ -3,7 +3,6 @@ import 'dart:developer'; import 'package:file_picker/file_picker.dart'; import 'package:fluffychat/pages/new_space/new_space_view.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart'; @@ -80,7 +79,6 @@ class NewSpaceController extends State { stateKey: '', content: { 'events': { - PangeaEventTypes.studentAnalyticsSummary: 0, EventTypes.spaceChild: 0, }, 'users_default': 0, diff --git a/lib/pangea/choreographer/controllers/igc_controller.dart b/lib/pangea/choreographer/controllers/igc_controller.dart index b0c8dd462..d5fd0de95 100644 --- a/lib/pangea/choreographer/controllers/igc_controller.dart +++ b/lib/pangea/choreographer/controllers/igc_controller.dart @@ -55,6 +55,9 @@ class IgcController { try { if (choreographer.currentText.isEmpty) return clear(); + // the error spans are going to be reloaded, so clear the cache + _clearCache(); + debugPrint('getIGCTextData called with ${choreographer.currentText}'); debugPrint('getIGCTextData called with tokensOnly = $tokensOnly'); @@ -287,6 +290,7 @@ class IgcController { clear() { igcTextData = null; + _clearCache(); // Not sure why this is here // MatrixState.pAnyState.closeOverlay(); } diff --git a/lib/pangea/constants/local.key.dart b/lib/pangea/constants/local.key.dart index c0390c2ba..743fe1143 100644 --- a/lib/pangea/constants/local.key.dart +++ b/lib/pangea/constants/local.key.dart @@ -11,4 +11,5 @@ class PLocalKey { static const String dismissedPaywall = 'dismissedPaywall'; static const String paywallBackoff = 'paywallBackoff'; static const String autoPlayMessages = 'autoPlayMessages'; + static const String messagesSinceUpdate = 'messagesSinceLastUpdate'; } diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index ef84a7064..0cd14e5a4 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -110,4 +110,7 @@ class ModelKey { "discussion_trigger_reaction_enabled"; static const String discussionTriggerReactionKey = "discussion_trigger_reaction_key"; + + static const String prevEventId = "prev_event_id"; + static const String prevLastUpdated = "prev_last_updated"; } diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index 9b5a520a1..a182728dd 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -6,14 +6,16 @@ class PangeaEventTypes { static const rules = "p.rules"; - static const studentAnalyticsSummary = "pangea.usranalytics"; + // static const studentAnalyticsSummary = "pangea.usranalytics"; + static const summaryAnalytics = "pangea.summaryAnalytics"; + static const construct = "pangea.construct"; static const translation = "pangea.translation"; static const tokens = "pangea.tokens"; static const choreoRecord = "pangea.record"; static const representation = "pangea.representation"; - static const vocab = "p.vocab"; + // static const vocab = "p.vocab"; static const roomInfo = "pangea.roomtopic"; static const audio = "p.audio"; diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 36d0bdad5..c0c8ecd1a 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -1,34 +1,39 @@ +import 'dart:async'; import 'dart:developer'; import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/time_span.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import '../constants/class_default_values.dart'; import '../extensions/client_extension/client_extension.dart'; import '../extensions/pangea_room_extension/pangea_room_extension.dart'; -import '../matrix_event_wrappers/construct_analytics_event.dart'; -import '../models/chart_analytics_model.dart'; -import '../models/student_analytics_event.dart'; +import '../models/analytics/chart_analytics_model.dart'; import 'base_controller.dart'; import 'pangea_controller.dart'; +// controls the fetching of analytics data class AnalyticsController extends BaseController { late PangeaController _pangeaController; - final List _cachedModels = []; + final List _cachedAnalyticsModels = []; final List _cachedConstructs = []; AnalyticsController(PangeaController pangeaController) : super() { _pangeaController = pangeaController; } + ///////// TIME SPANS ////////// String get _analyticsTimeSpanKey => "ANALYTICS_TIME_SPAN_KEY"; TimeSpan get currentAnalyticsTimeSpan { @@ -57,232 +62,480 @@ class AnalyticsController extends BaseController { ); } - Future> allClassAnalytics() async { - final List> classAnalyticFutures = []; - for (final classRoom in (await _pangeaController - .matrixState.client.classesAndExchangesImTeaching)) { - classAnalyticFutures.add( - getAnalytics(classRoom: classRoom), + Future myAnalyticsLastUpdated(String type) async { + // given an analytics event type, get the last updated times + // for each of the user's analytics rooms and return the most recent + // Most Recent instead of the oldest because, for instance: + // My last Spanish event was sent 3 days ago. + // My last English event was sent 1 day ago. + // When I go to check if the cached data is out of date, the cached item was set 2 days ago. + // I know there’s new data available because the English update data (the most recent) is after the cache’s creation time. + // So, I should update the cache. + final List analyticsRooms = _pangeaController + .matrixState.client.allMyAnalyticsRooms + .where((room) => room.isAnalyticsRoom) + .toList(); + + final List lastUpdates = []; + for (final Room analyticsRoom in analyticsRooms) { + final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( + type, + _pangeaController.matrixState.client.userID!, + ); + if (lastUpdated != null) { + lastUpdates.add(lastUpdated); + } + } + + if (lastUpdates.isEmpty) return null; + return lastUpdates.reduce( + (check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent, + ); + } + + Future spaceAnalyticsLastUpdated( + String type, + Room space, + String langCode, + ) async { + // check if any students have recently updated their analytics + // if any have, then the cache needs to be updated + // TODO - figure out how to do this on a per-student basis + await space.requestParticipants(); + + final List> lastUpdatedFutures = []; + for (final student in space.students) { + final Room? analyticsRoom = _pangeaController.matrixState.client + .analyticsRoomLocal(langCode, student.id); + if (analyticsRoom == null) continue; + lastUpdatedFutures.add( + analyticsRoom.analyticsLastUpdated( + type, + student.id, + ), ); } - return Future.wait(classAnalyticFutures); + final List lastUpdatedWithNulls = + await Future.wait(lastUpdatedFutures); + final List lastUpdates = + lastUpdatedWithNulls.where((e) => e != null).cast().toList(); + if (lastUpdates.isNotEmpty) { + return lastUpdates.reduce( + (check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent, + ); + } + return null; + } + + // Map of space ids to the last fetched hierarchy. Used when filtering + // private chat analytics to determine which children are already visible + // in the chat list + final Map> _lastFetchedHierarchies = {}; + void setLatestHierarchy(String spaceId, GetSpaceHierarchyResponse resp) { + final List roomIds = resp.rooms.map((room) => room.roomId).toList(); + _lastFetchedHierarchies[spaceId] = roomIds; + } + + //////////////////////////// MESSAGE SUMMARY ANALYTICS //////////////////////////// + + Future> mySummaryAnalytics() async { + // gets all the summary analytics events for the user + // since the current timespace's cut off date + final analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + final List allEvents = []; + + // TODO switch to using list of futures + for (final Room analyticsRoom in analyticsRooms) { + final List? roomEvents = + await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.summaryAnalytics, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: _pangeaController.matrixState.client.userID!, + ); + + allEvents.addAll( + roomEvents?.cast() ?? [], + ); + } + return allEvents; + } + + Future> spaceMemberAnalytics( + Room space, + ) async { + // gets all the summary analytics events for the students + // in a space since the current timespace's cut off date + final langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + + // ensure that all the space's events are loaded (mainly the for langCode) + // and that the participants are loaded + await space.postLoad(); + await space.requestParticipants(); + + // TODO switch to using list of futures + final List analyticsEvents = []; + for (final student in space.students) { + final Room? analyticsRoom = _pangeaController.matrixState.client + .analyticsRoomLocal(langCode, student.id); + + if (analyticsRoom != null) { + final List? roomEvents = + await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.summaryAnalytics, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: student.id, + ); + analyticsEvents.addAll( + roomEvents?.cast() ?? [], + ); + } + } + + final List spaceChildrenIds = space.allSpaceChildRoomIds; + + // filter out the analyics events that don't belong to the space's children + final List allAnalyticsEvents = []; + for (final analyticsEvent in analyticsEvents) { + analyticsEvent.content.messages.removeWhere( + (msg) => !spaceChildrenIds.contains(msg.chatId), + ); + allAnalyticsEvents.add(analyticsEvent); + } + + return allAnalyticsEvents; } ChartAnalyticsModel? getAnalyticsLocal({ TimeSpan? timeSpan, - String? classId, - String? studentId, - String? chatId, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, bool forceUpdate = false, bool updateExpired = false, + DateTime? lastUpdated, }) { timeSpan ??= currentAnalyticsTimeSpan; - final int index = _cachedModels.indexWhere( + final int index = _cachedAnalyticsModels.indexWhere( (e) => (e.timeSpan == timeSpan) && - (e.classId == classId) && - (e.studentId == studentId) && - (e.chatId == chatId), + (e.defaultSelected.id == defaultSelected.id) && + (e.defaultSelected.type == defaultSelected.type) && + (e.selected?.id == selected?.id) && + (e.selected?.type == selected?.type), ); if (index != -1) { - if ((updateExpired && _cachedModels[index].isExpired) || forceUpdate) { - _cachedModels.removeAt(index); + if ((updateExpired && _cachedAnalyticsModels[index].isExpired) || + forceUpdate || + _cachedAnalyticsModels[index].needsUpdate(lastUpdated)) { + _cachedAnalyticsModels.removeAt(index); } else { - return _cachedModels[index].chartAnalyticsModel; + return _cachedAnalyticsModels[index].chartAnalyticsModel; } } return null; } - Future getAnalytics({ + void cacheAnalytics({ + required ChartAnalyticsModel chartAnalyticsModel, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, TimeSpan? timeSpan, - Room? classRoom, - String? studentId, - String? chatId, - bool forceUpdate = false, - }) async { - timeSpan ??= currentAnalyticsTimeSpan; - try { - final cachedModel = getAnalyticsLocal( - classId: classRoom?.id, - studentId: studentId, - chatId: chatId, - updateExpired: true, - forceUpdate: forceUpdate, - ); - if (cachedModel != null) return cachedModel; - // debugger(when: classRoom?.displayname.contains('clizass') ?? false); - late List studentAnalyticsSummaryEvents; - if (classRoom == null) { - if (studentId == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "studentId should have been defined", - s: StackTrace.current, - ); - } else { - studentAnalyticsSummaryEvents = - await _pangeaController.myAnalytics.allMyAnalyticsEvents(); - } - } else { - if (studentId != null) { - studentAnalyticsSummaryEvents = [ - await classRoom.getStudentAnalytics(studentId), - ]; - } else { - studentAnalyticsSummaryEvents = await classRoom.getClassAnalytics(); - } - if (studentId != null && chatId != null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "studentId and chatId should have both been defined", - s: StackTrace.current, - ); - studentAnalyticsSummaryEvents = []; - } - } - - final List msgs = []; - for (final event in studentAnalyticsSummaryEvents) { - if (event != null) { - msgs.addAll(event.content.messages); - } else { - debugPrint("studentAnalyticsSummaryEvent is null"); - } - } - - final newModel = ChartAnalyticsModel( - timeSpan: timeSpan, - msgs: msgs, - chatId: chatId, - ); - - _cachedModels.add( - CacheModel( - timeSpan: timeSpan, - classId: classRoom?.id, - studentId: studentId, - chatId: chatId, - chartAnalyticsModel: newModel, - ), - ); - - return newModel; - } catch (err, s) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan, chatId: chatId); - } - } - - Future getAnalyticsForPrivateChats({ - TimeSpan? timeSpan, - required Room? classRoom, - bool forceUpdate = false, - }) async { - timeSpan ??= currentAnalyticsTimeSpan; - - try { - if (classRoom == null) { - return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan); - } - - final cachedModel = getAnalyticsLocal( - classId: classRoom.id, - studentId: null, - chatId: AnalyticsEntryType.privateChats.toString(), - updateExpired: true, - forceUpdate: forceUpdate, - ); - if (cachedModel != null) return cachedModel; - final List studentAnalyticsSummaryEvents = - await classRoom.getClassAnalytics(); - final List directChatIds = - classRoom.childrenAndGrandChildrenDirectChatIds; - - final List msgs = []; - for (final event in studentAnalyticsSummaryEvents) { - if (event != null) { - msgs.addAll( - event.content.messages - .where((m) => directChatIds.contains(m.chatId)), - ); - } else { - debugPrint("studentAnalyticsSummaryEvent is null"); - } - } - final newModel = ChartAnalyticsModel( - timeSpan: timeSpan, - msgs: msgs, - chatId: null, - ); - - _cachedModels.add( - CacheModel( - timeSpan: timeSpan, - classId: classRoom.id, - studentId: null, - chatId: AnalyticsEntryType.privateChats.toString(), - chartAnalyticsModel: newModel, - ), - ); - - return newModel; - } catch (err, s) { - debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan); - } - } - - List? _constructs; - bool settingConstructs = false; - - List? get constructs => _constructs; - - String? getLangCode({ - Room? space, - String? roomID, }) { - final String? targetRoomID = space?.id ?? roomID; - final String? roomLangCode = - _pangeaController.languageController.activeL2Code(roomID: targetRoomID); - final String? userLangCode = - _pangeaController.languageController.userL2?.langCode; - - return roomLangCode ?? userLangCode; + _cachedAnalyticsModels.add( + AnalyticsCacheModel( + timeSpan: timeSpan ?? currentAnalyticsTimeSpan, + chartAnalyticsModel: chartAnalyticsModel, + defaultSelected: defaultSelected, + selected: selected, + ), + ); } - Future myAnalyticsRoom(String langCode) => - _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode); + List filterStudentAnalytics( + List unfiltered, + String? studentId, + ) { + final List filtered = + List.from(unfiltered); + filtered.removeWhere((e) => e.event.senderId != studentId); + return filtered; + } - Room? studentAnalyticsRoom(String studentId, String langCode) => - _pangeaController.matrixState.client.analyticsRoomLocal( - langCode, - studentId, + List filterRoomAnalytics( + List unfiltered, + String? roomID, + ) { + List filtered = [...unfiltered]; + Room? room; + if (roomID != null) { + room = _pangeaController.matrixState.client.getRoomById(roomID); + if (room?.isSpace == true) { + return filterSpaceAnalytics(unfiltered, roomID); + } + } + + filtered = filtered + .where( + (e) => (e.content).messages.any((u) => u.chatId == roomID), + ) + .toList(); + filtered.forEachIndexed( + (i, _) => (filtered[i].content).messages.removeWhere( + (u) => u.chatId != roomID, + ), + ); + return filtered; + } + + Future> filterPrivateChatAnalytics( + List unfiltered, + Room? space, + ) async { + if (space != null && !_lastFetchedHierarchies.containsKey(space.id)) { + final resp = await _pangeaController.matrixState.client + .getSpaceHierarchy(space.id); + setLatestHierarchy(space.id, resp); + } + + final List privateChatIds = space?.allSpaceChildRoomIds ?? []; + final List lastFetched = _lastFetchedHierarchies[space!.id] ?? []; + for (final id in lastFetched) { + privateChatIds.removeWhere((e) => e == id); + } + + List filtered = + List.from(unfiltered); + filtered = filtered.where((e) { + return (e.content).messages.any( + (u) => privateChatIds.contains(u.chatId), + ); + }).toList(); + filtered.forEachIndexed( + (i, _) => (filtered[i].content).messages.removeWhere( + (u) => !privateChatIds.contains(u.chatId), + ), + ); + return filtered; + } + + List filterSpaceAnalytics( + List unfiltered, + String spaceId, + ) { + final selectedSpace = + _pangeaController.matrixState.client.getRoomById(spaceId); + final List chatIds = selectedSpace?.spaceChildren + .map((e) => e.roomId) + .where((e) => e != null) + .cast() + .toList() ?? + []; + + List filtered = + List.from(unfiltered); + + filtered = filtered + .where( + (e) => e.content.messages.any((u) => chatIds.contains(u.chatId)), + ) + .toList(); + + filtered.forEachIndexed( + (i, _) => (filtered[i].content).messages.removeWhere( + (u) => !chatIds.contains(u.chatId), + ), + ); + return filtered; + } + + Future> filterAnalytics({ + required List unfilteredAnalytics, + required AnalyticsSelected defaultSelected, + Room? space, + AnalyticsSelected? selected, + }) async { + for (int i = 0; i < unfilteredAnalytics.length; i++) { + unfilteredAnalytics[i].content.messages.removeWhere( + (record) => record.time.isBefore( + currentAnalyticsTimeSpan.cutOffDate, + ), + ); + } + + switch (selected?.type) { + case null: + return unfilteredAnalytics; + case AnalyticsEntryType.student: + if (defaultSelected.type != AnalyticsEntryType.space) { + throw Exception( + "student filtering not available for default filter ${defaultSelected.type}", + ); + } + return filterStudentAnalytics(unfilteredAnalytics, selected?.id); + case AnalyticsEntryType.room: + return filterRoomAnalytics(unfilteredAnalytics, selected?.id); + case AnalyticsEntryType.privateChats: + if (defaultSelected.type == AnalyticsEntryType.student) { + throw "private chat filtering not available for my analytics"; + } + return await filterPrivateChatAnalytics( + unfilteredAnalytics, + space, + ); + case AnalyticsEntryType.space: + return filterSpaceAnalytics(unfilteredAnalytics, selected!.id); + default: + throw Exception("invalid filter type - ${selected?.type}"); + } + } + + Future getAnalytics({ + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + bool forceUpdate = false, + }) async { + try { + await _pangeaController.matrixState.client.roomsLoading; + + // if the user is looking at space analytics, then fetch the space + Room? space; + String? langCode; + if (defaultSelected.type == AnalyticsEntryType.space) { + space = _pangeaController.matrixState.client.getRoomById( + defaultSelected.id, + ); + if (space == null) { + ErrorHandler.logError( + m: "space not found in getAnalytics", + data: { + "defaultSelected": defaultSelected, + "selected": selected, + }, + ); + return ChartAnalyticsModel( + msgs: [], + timeSpan: currentAnalyticsTimeSpan, + ); + } + + await space.postLoad(); + langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + if (langCode == null) { + ErrorHandler.logError( + m: "langCode missing in getAnalytics", + data: { + "space": space, + }, + ); + return ChartAnalyticsModel( + msgs: [], + timeSpan: currentAnalyticsTimeSpan, + ); + } + } + + DateTime? lastUpdated; + if (defaultSelected.type != AnalyticsEntryType.space) { + // if default selected view is my analytics, check for the last + // time the logged in user updated their analytics events + // this gets passed to getAnalyticsLocal to determine if the cached + // entry is out-of-date + lastUpdated = await myAnalyticsLastUpdated( + PangeaEventTypes.summaryAnalytics, + ); + } else { + // else, get the last time a student in the space updated their analytics + lastUpdated = await spaceAnalyticsLastUpdated( + PangeaEventTypes.summaryAnalytics, + space!, + langCode!, + ); + } + + final ChartAnalyticsModel? local = getAnalyticsLocal( + defaultSelected: defaultSelected, + selected: selected, + forceUpdate: forceUpdate, + lastUpdated: lastUpdated, + ); + if (local != null && !forceUpdate) { + return local; + } + + // get all the relevant summary analytics events for the current timespan + final List summaryEvents = + defaultSelected.type == AnalyticsEntryType.space + ? await spaceMemberAnalytics(space!) + : await mySummaryAnalytics(); + + // filter out the analytics events based on filters the user has chosen + final List filteredAnalytics = + await filterAnalytics( + unfilteredAnalytics: summaryEvents, + defaultSelected: defaultSelected, + space: space, + selected: selected, ); - Future> allMyConstructs( - String langCode, { - ConstructType? type, - }) async { - final Room analyticsRoom = await myAnalyticsRoom(langCode); + // then create and return the model to be displayed + final ChartAnalyticsModel newModel = ChartAnalyticsModel( + timeSpan: currentAnalyticsTimeSpan, + msgs: filteredAnalytics + .map((event) => event.content.messages) + .expand((msgs) => msgs) + .toList(), + ); + + cacheAnalytics( + chartAnalyticsModel: newModel, + defaultSelected: defaultSelected, + selected: selected, + timeSpan: currentAnalyticsTimeSpan, + ); + + return newModel; + } catch (err, s) { + debugger(when: kDebugMode); + ErrorHandler.logError(e: err, s: s); + return ChartAnalyticsModel( + msgs: [], + timeSpan: currentAnalyticsTimeSpan, + ); + } + } + + //////////////////////////// CONSTRUCTS //////////////////////////// + + Future> allMyConstructs() async { + final List analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + final List allConstructs = []; + for (final Room analyticsRoom in analyticsRooms) { + final List? roomEvents = + (await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.construct, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: _pangeaController.matrixState.client.userID!, + )) + ?.cast(); + allConstructs.addAll(roomEvents ?? []); + } + final List adminSpaceRooms = await _pangeaController.matrixState.client.teacherRoomIds; - - final allConstructs = type == null - ? await analyticsRoom.allConstructEvents - : (await analyticsRoom.allConstructEvents) - .where((e) => e.content.type == type) - .toList(); - - for (int i = 0; i < allConstructs.length; i++) { - final construct = allConstructs[i]; - final uses = construct.content.uses; - uses.removeWhere((u) => adminSpaceRooms.contains(u.chatId)); + for (final construct in allConstructs) { + construct.content.uses.removeWhere( + (use) => adminSpaceRooms.contains(use.chatId), + ); } return allConstructs @@ -290,91 +543,94 @@ class AnalyticsController extends BaseController { .toList(); } - Future> allSpaceMemberConstructs( + Future> allSpaceMemberConstructs( Room space, - String langCode, { - ConstructType? type, - }) async { - final List>> constructEventFutures = []; + ) async { await space.postLoad(); await space.requestParticipants(); - for (final student in space.students) { - final Room? room = _pangeaController.matrixState.client - .analyticsRoomLocal(langCode, student.id); - if (room != null) constructEventFutures.add(room.allConstructEvents); - } + final String? langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); - final List> constructLists = - await Future.wait(constructEventFutures); - - final List spaceChildrenIds = space.spaceChildren - .map((e) => e.roomId) - .where((e) => e != null) - .cast() - .toList(); - - final List allConstructs = []; - for (final constructList in constructLists) { - for (int i = 0; i < constructList.length; i++) { - final construct = constructList[i]; - final uses = construct.content.uses; - uses.removeWhere((u) => !spaceChildrenIds.contains(u.chatId)); - } - allConstructs.addAll( - constructList.where((e) => e.content.uses.isNotEmpty), + if (langCode == null) { + ErrorHandler.logError( + m: "langCode missing in allSpaceMemberConstructs", + data: { + "space": space, + }, ); + return []; } - return type == null - ? allConstructs - : allConstructs.where((e) => e.content.type == type).toList(); + final List constructEvents = []; + for (final student in space.students) { + final Room? analyticsRoom = _pangeaController.matrixState.client + .analyticsRoomLocal(langCode, student.id); + if (analyticsRoom != null) { + final List? roomEvents = + (await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.construct, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: student.id, + )) + ?.cast(); + constructEvents.addAll(roomEvents ?? []); + } + } + + final List spaceChildrenIds = space.allSpaceChildRoomIds; + final List allConstructs = []; + for (final constructEvent in constructEvents) { + constructEvent.content.uses.removeWhere( + (use) => !spaceChildrenIds.contains(use.chatId), + ); + + if (constructEvent.content.uses.isNotEmpty) { + allConstructs.add(constructEvent); + } + } + + return allConstructs; } - List filterStudentConstructs( - List unfilteredConstructs, + List filterStudentConstructs( + List unfilteredConstructs, String? studentId, ) { - final List filtered = - List.from(unfilteredConstructs); - filtered.removeWhere((e) => e.event.senderId != studentId); + final List filtered = + List.from(unfilteredConstructs); + filtered.removeWhere((element) => element.event.senderId != studentId); return filtered; } - List filterRoomConstructs( - List unfilteredConstructs, + List filterRoomConstructs( + List unfilteredConstructs, String? roomID, ) { - List filtered = [...unfilteredConstructs]; - filtered = unfilteredConstructs - .where((e) => e.content.uses.any((u) => u.chatId == roomID)) - .toList(); - filtered.forEachIndexed( - (i, _) => filtered[i].content.uses.removeWhere((u) => u.chatId != roomID), - ); + final List filtered = [...unfilteredConstructs]; + for (final construct in filtered) { + construct.content.uses.removeWhere((u) => u.chatId != roomID); + } return filtered; } - List filterPrivateChatConstructs( - List unfilteredConstructs, + List filterPrivateChatConstructs( + List unfilteredConstructs, Room parentSpace, ) { - final List directChatIds = - parentSpace.childrenAndGrandChildrenDirectChatIds; - List filtered = - List.from(unfilteredConstructs); - filtered = filtered.where((e) { - return e.content.uses.any((u) => directChatIds.contains(u.chatId)); - }).toList(); - filtered.forEachIndexed( - (i, _) => filtered[i].content.uses.removeWhere( - (u) => !directChatIds.contains(u.chatId), - ), - ); + final List directChatIds = []; + final List filtered = + List.from(unfilteredConstructs); + for (final construct in filtered) { + construct.content.uses.removeWhere( + (use) => !directChatIds.contains(use.chatId), + ); + } return filtered; } - List filterSpaceConstructs( - List unfilteredConstructs, + List filterSpaceConstructs( + List unfilteredConstructs, Room space, ) { final List chatIds = space.spaceChildren @@ -383,67 +639,69 @@ class AnalyticsController extends BaseController { .cast() .toList(); - List filtered = - List.from(unfilteredConstructs); - filtered = filtered - .where((e) => e.content.uses.any((u) => chatIds.contains(u.chatId))) - .toList(); + final List filtered = + List.from(unfilteredConstructs); + + for (final construct in filtered) { + construct.content.uses.removeWhere( + (use) => !chatIds.contains(use.chatId), + ); + } - filtered.forEachIndexed( - (i, _) => filtered[i].content.uses.removeWhere( - (u) => !chatIds.contains(u.chatId), - ), - ); return filtered; } - List? getConstructsLocal({ + List? getConstructsLocal({ required TimeSpan timeSpan, required ConstructType constructType, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, + DateTime? lastUpdated, }) { - final cachedEntry = _cachedConstructs - .firstWhereOrNull( - (e) => - e.timeSpan == timeSpan && - e.type == constructType && - e.defaultSelected.id == defaultSelected.id && - e.defaultSelected.type == defaultSelected.type && - e.selected?.id == selected?.id && - e.selected?.type == selected?.type, - ) - ?.events; - return cachedEntry; + final index = _cachedConstructs.indexWhere( + (e) => + e.timeSpan == timeSpan && + e.type == constructType && + e.defaultSelected.id == defaultSelected.id && + e.defaultSelected.type == defaultSelected.type && + e.selected?.id == selected?.id && + e.selected?.type == selected?.type, + ); + + if (index > -1) { + if (_cachedConstructs[index].needsUpdate(lastUpdated)) { + _cachedConstructs.removeAt(index); + return null; + } + return _cachedConstructs[index].events; + } + + return null; } void cacheConstructs({ required ConstructType constructType, - required List events, + required List events, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, }) { - _cachedConstructs.add( - ConstructCacheEntry( - timeSpan: currentAnalyticsTimeSpan, - type: constructType, - events: events, - defaultSelected: defaultSelected, - selected: selected, - ), + final entry = ConstructCacheEntry( + timeSpan: currentAnalyticsTimeSpan, + type: constructType, + events: List.from(events), + defaultSelected: defaultSelected, + selected: selected, ); + _cachedConstructs.add(entry); } - Future> getMyConstructs({ + Future> getMyConstructs({ required AnalyticsSelected defaultSelected, required ConstructType constructType, - required String langCode, AnalyticsSelected? selected, }) async { - final List unfilteredConstructs = await allMyConstructs( - langCode, - type: constructType, - ); + final List unfilteredConstructs = + await allMyConstructs(); final Room? space = selected?.type == AnalyticsEntryType.space ? _pangeaController.matrixState.client.getRoomById(selected!.id) @@ -451,39 +709,33 @@ class AnalyticsController extends BaseController { return filterConstructs( unfilteredConstructs: unfilteredConstructs, - langCode: langCode, space: space, defaultSelected: defaultSelected, selected: selected, ); } - Future> getSpaceConstructs({ + Future> getSpaceConstructs({ required ConstructType constructType, required Room space, required AnalyticsSelected defaultSelected, - required String langCode, AnalyticsSelected? selected, }) async { - final List unfilteredConstructs = + final List unfilteredConstructs = await allSpaceMemberConstructs( space, - langCode, - type: constructType, ); return filterConstructs( unfilteredConstructs: unfilteredConstructs, - langCode: langCode, space: space, defaultSelected: defaultSelected, selected: selected, ); } - Future> filterConstructs({ - required List unfilteredConstructs, - required String langCode, + Future> filterConstructs({ + required List unfilteredConstructs, required AnalyticsSelected defaultSelected, Room? space, AnalyticsSelected? selected, @@ -495,9 +747,8 @@ class AnalyticsController extends BaseController { for (int i = 0; i < unfilteredConstructs.length; i++) { final construct = unfilteredConstructs[i]; - final uses = construct.content.uses; - uses.removeWhere( - (u) => u.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate), + construct.content.uses.removeWhere( + (use) => use.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate), ); } @@ -512,12 +763,7 @@ class AnalyticsController extends BaseController { "student filtering not available for default filter ${defaultSelected.type}", ); } - final Room? analyticsRoom = - studentAnalyticsRoom(selected!.id, langCode); - if (analyticsRoom == null) { - throw Exception("analyticsRoom missing in filterConstructs"); - } - return filterStudentConstructs(unfilteredConstructs, selected.id); + return filterStudentConstructs(unfilteredConstructs, selected!.id); case AnalyticsEntryType.room: return filterRoomConstructs(unfilteredConstructs, selected?.id); case AnalyticsEntryType.privateChats: @@ -531,123 +777,122 @@ class AnalyticsController extends BaseController { } } - Future?> setConstructs({ + Future?> getConstructs({ required ConstructType constructType, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, - bool removeIT = false, + bool removeIT = true, bool forceUpdate = false, }) async { - final List? local = getConstructsLocal( - timeSpan: currentAnalyticsTimeSpan, - constructType: constructType, - defaultSelected: defaultSelected, - selected: selected, - ); - if (local != null && !forceUpdate) { - _constructs = local; - return _constructs; - } - - if (settingConstructs) return _constructs; - settingConstructs = true; await _pangeaController.matrixState.client.roomsLoading; + Room? space; + String? langCode; if (defaultSelected.type == AnalyticsEntryType.space) { space = _pangeaController.matrixState.client.getRoomById( defaultSelected.id, ); + if (space == null) { + ErrorHandler.logError( + m: "space not found in setConstructs", + data: { + "defaultSelected": defaultSelected, + "selected": selected, + }, + ); + return []; + } + await space.postLoad(); + langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + if (langCode == null) { + ErrorHandler.logError( + m: "langCode missing in setConstructs", + data: { + "space": space, + }, + ); + return []; + } } - final String? roomID = space?.id ?? selected?.id; - final String? langCode = getLangCode( - space: space, - roomID: roomID, - ); - - if (langCode == null) { - ErrorHandler.logError( - m: "langCode missing in getConstructs", - data: { - "constructType": constructType, - "AnalyticsEntryType": defaultSelected.type, - "AnalyticsEntryId": defaultSelected.id, - "space": space, - }, + DateTime? lastUpdated; + if (defaultSelected.type != AnalyticsEntryType.space) { + // if default selected view is my analytics, check for the last + // time the logged in user updated their analytics events + // this gets passed to getAnalyticsLocal to determine if the cached + // entry is out-of-date + lastUpdated = await myAnalyticsLastUpdated( + PangeaEventTypes.construct, ); - throw "langCode missing in getConstructs"; + } else { + // else, get the last time a student in the space updated their analytics + lastUpdated = await spaceAnalyticsLastUpdated( + PangeaEventTypes.construct, + space!, + langCode!, + ); + } + + final List? local = getConstructsLocal( + timeSpan: currentAnalyticsTimeSpan, + constructType: constructType, + defaultSelected: defaultSelected, + selected: selected, + lastUpdated: lastUpdated, + ); + if (local != null && !forceUpdate) { + return local; } final filteredConstructs = space == null ? await getMyConstructs( constructType: constructType, - langCode: langCode, defaultSelected: defaultSelected, selected: selected, ) : await getSpaceConstructs( constructType: constructType, space: space, - langCode: langCode, defaultSelected: defaultSelected, selected: selected, ); - _constructs = removeIT - ? filteredConstructs - .where( - (element) => - element.content.lemma != "Try interactive translation" && - element.content.lemma != "itStart" && - element.content.lemma != MatchRuleIds.interactiveTranslation, - ) - .toList() - : filteredConstructs; + if (removeIT) { + for (final construct in filteredConstructs) { + construct.content.uses.removeWhere( + (element) => + element.lemma == "Try interactive translation" || + element.lemma == "itStart" || + element.lemma == MatchRuleIds.interactiveTranslation, + ); + } + } if (local == null) { cacheConstructs( constructType: constructType, - events: _constructs!, + events: filteredConstructs, defaultSelected: defaultSelected, selected: selected, ); } - settingConstructs = false; - return _constructs; + return filteredConstructs; } } -class ConstructCacheEntry { +abstract class CacheEntry { final TimeSpan timeSpan; - final ConstructType type; - final List events; final AnalyticsSelected defaultSelected; AnalyticsSelected? selected; + late final DateTime _createdAt; - ConstructCacheEntry({ + CacheEntry({ required this.timeSpan, - required this.type, - required this.events, required this.defaultSelected, this.selected, - }); -} - -class CacheModel { - TimeSpan timeSpan; - ChartAnalyticsModel chartAnalyticsModel; - String? classId; - String? chatId; - String? studentId; - late DateTime _createdAt; - - CacheModel({ - required this.timeSpan, - required this.classId, - required this.chartAnalyticsModel, - required this.chatId, - required this.studentId, }) { _createdAt = DateTime.now(); } @@ -655,11 +900,47 @@ class CacheModel { bool get isExpired => DateTime.now().difference(_createdAt).inMinutes > ClassDefaultValues.minutesDelayToMakeNewChartAnalytics; + + bool needsUpdate(DateTime? lastEventUpdated) { + // cache entry is invalid if it's older than the last event update + // if lastEventUpdated is null, that would indicate that no events + // of this type have been sent to the room. In this case, there + // shouldn't be any cached data. + if (lastEventUpdated == null) { + Sentry.addBreadcrumb( + Breadcrumb(message: "lastEventUpdated is null in needsUpdate"), + ); + return false; + } + return _createdAt.isBefore(lastEventUpdated); + } } -// class ListTotals { -// String listName; -// ConstructUses vocabUse; +class ConstructCacheEntry extends CacheEntry { + final ConstructType type; + final List events; -// ListTotals({required this.listName, required this.vocabUse}); -// } + ConstructCacheEntry({ + required this.type, + required this.events, + required super.timeSpan, + required super.defaultSelected, + super.selected, + }); +} + +class AnalyticsCacheModel extends CacheEntry { + ChartAnalyticsModel chartAnalyticsModel; + + AnalyticsCacheModel({ + required this.chartAnalyticsModel, + required super.timeSpan, + required super.defaultSelected, + super.selected, + }); + + @override + bool get isExpired => + DateTime.now().difference(_createdAt).inMinutes > + ClassDefaultValues.minutesDelayToMakeNewChartAnalytics; +} diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 0f17eaa20..cd95dd002 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,159 +1,313 @@ import 'dart:async'; import 'dart:developer'; +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/matrix_event_wrappers/construct_analytics_event.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import '../extensions/client_extension/client_extension.dart'; import '../extensions/pangea_room_extension/pangea_room_extension.dart'; -import '../models/constructs_analytics_model.dart'; -import '../models/student_analytics_event.dart'; -class MyAnalyticsController { +// controls the sending of analytics events +class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; + Timer? _updateTimer; + final int _maxMessagesCached = 10; + final int _minutesBeforeUpdate = 5; MyAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; } - String? get _userId => _pangeaController.matrixState.client.userID; + // adds the listener that handles when to run automatic updates + // to analytics - either after a certain number of messages sent/ + // received or after a certain amount of time without an update + Future addEventsListener() async { + final Client client = _pangeaController.matrixState.client; - //PTODO - locally cache and update periodically - Future handleMessage( - Room room, - RecentMessageRecord messageRecord, { - bool isEdit = false, - }) async { - try { - debugPrint("in handle message with type ${messageRecord.useType}"); - if (_userId == null) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "null userId in updateAnalytics", - s: StackTrace.current, - ); - return; - } + // if analytics haven't been updated in the last day, update them + DateTime? lastUpdated = await _pangeaController.analytics + .myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics); + final DateTime yesterday = DateTime.now().subtract(const Duration(days: 1)); + if (lastUpdated?.isBefore(yesterday) ?? true) { + debugPrint("analytics out-of-date, updating"); + await updateAnalytics(); + lastUpdated = await _pangeaController.analytics + .myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics); + } - await _pangeaController.classController.addDirectChatsToClasses(room); - //expanding this to all parents of the room - // final List spaces = room.immediateClassParents; - final List spaces = room.pangeaSpaceParents; - // janky but probably stays until we have a class analytics bot added by - // default to all chats + client.onSync.stream + .where((SyncUpdate update) => update.rooms?.join != null) + .listen((update) { + updateAnalyticsTimer(update, lastUpdated); + }); + } - final List events = await analyticsEvents(spaces); + // given an update from sync stream, check if the update contains + // messages for which analytics will be saved. If so, reset the timer + // and add the event ID to the cache of un-added event IDs + void updateAnalyticsTimer(SyncUpdate update, DateTime? lastUpdated) { + for (final entry in update.rooms!.join!.entries) { + final Room room = + _pangeaController.matrixState.client.getRoomById(entry.key)!; + // get the new events in this sync that are messages + final List? events = entry.value.timeline?.events + ?.map((event) => Event.fromMatrixEvent(event, room)) + .where((event) => eventHasAnalytics(event, lastUpdated)) + .toList(); + + // add their event IDs to the cache of un-added event IDs + if (events == null || events.isEmpty) continue; for (final event in events) { - if (event != null) { - event.handleNewMessage(messageRecord, isEdit: isEdit); - } + addMessageSinceUpdate(event.eventId); } + + // cancel the last timer that was set on message event and + // reset it to fire after _minutesBeforeUpdate minutes + _updateTimer?.cancel(); + _updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () { + debugPrint("timer fired, updating analytics"); + updateAnalytics(); + }); + } + } + + // checks if event from sync update is a message that should have analytics + bool eventHasAnalytics(Event event, DateTime? lastUpdated) { + return (lastUpdated == null || event.originServerTs.isAfter(lastUpdated)) && + event.type == EventTypes.Message && + event.messageType == MessageTypes.Text && + !(event.eventId.contains("web") && + !(event.eventId.contains("android")) && + !(event.eventId.contains("iOS"))); + } + + // adds an event ID to the cache of un-added event IDs + // if the event IDs isn't already added + void addMessageSinceUpdate(String eventId) { + final List currentCache = messagesSinceUpdate; + if (!currentCache.contains(eventId)) { + currentCache.add(eventId); + _pangeaController.pStoreService.save( + PLocalKey.messagesSinceUpdate, + currentCache, + local: true, + ); + } + + // if the cached has reached if max-length, update analytics + if (messagesSinceUpdate.length > _maxMessagesCached) { + debugPrint("reached max messages, updating"); + updateAnalytics(); + } + } + + // called before updating analytics + void clearMessagesSinceUpdate() { + _pangeaController.pStoreService.save( + PLocalKey.messagesSinceUpdate, + [], + local: true, + ); + } + + // a local cache of eventIds for messages sent since the last update + // it's possible for this cache to be invalid or deleted + // It's a proxy measure for messages sent since last update + List get messagesSinceUpdate { + final dynamic locallySaved = _pangeaController.pStoreService.read( + PLocalKey.messagesSinceUpdate, + local: true, + ); + if (locallySaved == null) { + _pangeaController.pStoreService.save( + PLocalKey.messagesSinceUpdate, + [], + local: true, + ); + return []; + } + + try { + return locallySaved as List; + } catch (err) { + _pangeaController.pStoreService.save( + PLocalKey.messagesSinceUpdate, + [], + local: true, + ); + return []; + } + } + + Future updateAnalytics() async { + // top level analytics sending function. Send analytics + // for each type of analytics event + // to each of the applicable analytics rooms + clearMessagesSinceUpdate(); + + // fetch a list of all the chats that the user is studying + // and a list of all the spaces in which the user is studying + await setStudentChats(); + await setStudentSpaces(); + + // get all the analytics rooms that the user has + // and create any missing analytics rooms (if the user is studying + // in a class but doesn't have an analytics room for that class's L2) + final List analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + analyticsRooms.addAll(await createMissingAnalyticsRooms()); + + // finally, send an analytics event for each analytics room and + // each type of analytics event + for (final Room analyticsRoom in analyticsRooms) { + for (final String type in AnalyticsEvent.analyticsEventTypes) { + await sendAnalyticsEvent(analyticsRoom, type); + } + } + } + + Future sendAnalyticsEvent( + Room analyticsRoom, + String type, + ) async { + // given an analytics room for a language and a type of analytics event + // gathers all the relevant data and sends it to the analytics room + + // get the language code for the analytics room + final String? langCode = analyticsRoom.madeForLang; + if (langCode == null) { + ErrorHandler.logError( + e: "no lang code found for analytics room: ${analyticsRoom.id}", + s: StackTrace.current, + ); + return; + } + + // get the last time an analytics event of this type was sent to this room + final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( + type, + _pangeaController.matrixState.client.userID!, + ); + + // each type of analytics event has a format for storing per-message data + // for SummaryAnalytics events, this is RecentMessageRecord + // for Construct events, this is OneConstructUse + // analyticsContent is a list of these formatted data + final List analyticsContent = []; + + for (final Room chat in _studentChats) { + // for each chat the student studies in, check if the langCode + // matches the langCode of the analytics room + final String? chatLangCode = + _pangeaController.languageController.activeL2Code(roomID: chat.id); + if (chatLangCode != langCode) continue; + + // get messages the logged in user has sent in all chats + // since the last analytics event was sent + List? recentMsgs; + try { + recentMsgs = await chat.myMessageEventsInChat( + since: lastUpdated, + ); + } catch (err) { + debugPrint("failed to fetch messages for chat ${chat.id}"); + continue; + } + + if (lastUpdated != null) { + recentMsgs.removeWhere( + (msg) => msg.event.originServerTs.isBefore(lastUpdated), + ); + } + + // then format that data into analytics data and add the formatted + // data to the list of analyticsContent + analyticsContent.addAll( + AnalyticsModel.formatAnalyticsContent(recentMsgs, type), + ); + } + + // send the analytics data to the analytics room + if (analyticsContent.isEmpty) return; + await AnalyticsEvent.sendEvent( + analyticsRoom, + type, + analyticsContent, + ); + } + + // on the off chance that the user is in a class but doesn't have an analytics + // room for the target language of that class, create the analytics room(s) + Future> createMissingAnalyticsRooms() async { + List targetLangs = []; + final String? userL2 = _pangeaController.languageController.activeL2Code(); + if (userL2 != null) targetLangs.add(userL2); + final List spaceL2s = studentSpaces + .map( + (space) => _pangeaController.languageController.activeL2Code( + roomID: space.id, + ), + ) + .toList(); + targetLangs.addAll(spaceL2s.where((l2) => l2 != null).cast()); + targetLangs = targetLangs.toSet().toList(); + for (final String langCode in targetLangs) { + await _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode); + } + return _pangeaController.matrixState.client.allMyAnalyticsRooms; + } + + List _studentChats = []; + + Future setStudentChats() async { + final List teacherRoomIds = + await _pangeaController.matrixState.client.teacherRoomIds; + _studentChats = _pangeaController.matrixState.client.rooms + .where( + (r) => + !r.isSpace && + !r.isAnalyticsRoom && + !teacherRoomIds.contains(r.id), + ) + .toList(); + setState(data: _studentChats); + } + + List get studentChats { + try { + if (_studentChats.isNotEmpty) return _studentChats; + setStudentChats(); + return _studentChats; } catch (err) { debugger(when: kDebugMode); + return []; } } - Future> analyticsEvents( - List spaces, - ) async { - final List> events = []; - if (_userId != null) { - for (final space in spaces) { - events.add(space.getStudentAnalytics(_userId!)); - } - } - return Future.wait(events); + List _studentSpaces = []; + + Future setStudentSpaces() async { + _studentSpaces = await _pangeaController + .matrixState.client.classesAndExchangesImStudyingIn; } - Future> allMyAnalyticsEvents() async => - analyticsEvents( - await _pangeaController - .matrixState.client.classesAndExchangesImStudyingIn, - ); - - Future saveConstructsMixed( - List allUses, - String langCode, { - bool isEdit = false, - }) async { + List get studentSpaces { try { - final Map> aggregatedVocabUse = {}; - for (final use in allUses) { - if (use.lemma == null) continue; - aggregatedVocabUse[use.lemma!] ??= []; - aggregatedVocabUse[use.lemma]!.add(use); - } - final Room analyticsRoom = await _pangeaController.matrixState.client - .getMyAnalyticsRoom(langCode); - - final List> saveFutures = []; - for (final uses in aggregatedVocabUse.entries) { - debugPrint("saving of type ${uses.value.first.constructType}"); - saveFutures.add( - analyticsRoom.saveConstructUsesSameLemma( - uses.key, - uses.value.first.constructType ?? ConstructType.grammar, - uses.value, - isEdit: isEdit, - ), - ); - } - - await Future.wait(saveFutures); - } catch (err, s) { + if (_studentSpaces.isNotEmpty) return _studentSpaces; + setStudentSpaces(); + return _studentSpaces; + } catch (err) { debugger(when: kDebugMode); - if (!kDebugMode) rethrow; - ErrorHandler.logError(e: err, s: s); + return []; } } - - // used to aggregate ConstructEvents, from multiple senders (students) with the same lemma - List aggregateConstructData( - List constructs, - ) { - final Map> lemmasToConstructs = {}; - for (final construct in constructs) { - lemmasToConstructs[construct.content.lemma] ??= []; - lemmasToConstructs[construct.content.lemma]!.add(construct); - } - - final List aggregatedConstructs = []; - for (final lemmaToConstructs in lemmasToConstructs.entries) { - final List lemmaConstructs = lemmaToConstructs.value; - final AggregateConstructUses aggregatedData = AggregateConstructUses( - constructs: lemmaConstructs, - ); - aggregatedConstructs.add(aggregatedData); - } - return aggregatedConstructs; - } -} - -class AggregateConstructUses { - final List _constructs; - - AggregateConstructUses({required List constructs}) - : _constructs = constructs; - - String get lemma { - assert( - _constructs.isNotEmpty && - _constructs.every( - (construct) => - construct.content.lemma == _constructs.first.content.lemma, - ), - ); - return _constructs.first.content.lemma; - } - - List get uses => _constructs - .map((construct) => construct.content.uses) - .expand((element) => element) - .toList(); } diff --git a/lib/pangea/enum/bar_chart_view_enum.dart b/lib/pangea/enum/bar_chart_view_enum.dart index d59f34d9a..3fe812634 100644 --- a/lib/pangea/enum/bar_chart_view_enum.dart +++ b/lib/pangea/enum/bar_chart_view_enum.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; enum BarChartViewSelection { @@ -30,4 +29,15 @@ extension BarChartViewSelectionExtension on BarChartViewSelection { return Icons.spellcheck_outlined; } } + + String get route { + switch (this) { + case BarChartViewSelection.messages: + return 'messages'; + // case BarChartViewSelection.vocab: + // return 'vocab'; + case BarChartViewSelection.grammar: + return 'errors'; + } + } } diff --git a/lib/pangea/enum/time_span.dart b/lib/pangea/enum/time_span.dart index 23a54e4ea..ddc9ce32b 100644 --- a/lib/pangea/enum/time_span.dart +++ b/lib/pangea/enum/time_span.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import '../models/chart_analytics_model.dart'; +import '../models/analytics/chart_analytics_model.dart'; enum TimeSpan { day, week, month, sixmonths, year } diff --git a/lib/pangea/extensions/client_extension/classes_and_exchanges_extension.dart b/lib/pangea/extensions/client_extension/classes_and_exchanges_extension.dart index 3108a90f5..af1df62a0 100644 --- a/lib/pangea/extensions/client_extension/classes_and_exchanges_extension.dart +++ b/lib/pangea/extensions/client_extension/classes_and_exchanges_extension.dart @@ -38,7 +38,13 @@ extension ClassesAndExchangesClientExtension on Client { .toList(); Future> get _classesAndExchangesImStudyingIn async { - for (final Room space in rooms.where((room) => room.isSpace)) { + final List joinedSpaces = rooms + .where( + (room) => room.isSpace && room.membership == Membership.join, + ) + .toList(); + + for (final Room space in joinedSpaces) { if (space.getState(EventTypes.RoomPowerLevels) == null) { await space.postLoad(); } diff --git a/lib/pangea/extensions/client_extension/client_analytics_extension.dart b/lib/pangea/extensions/client_extension/client_analytics_extension.dart index 9cd00df23..6057b5a87 100644 --- a/lib/pangea/extensions/client_extension/client_analytics_extension.dart +++ b/lib/pangea/extensions/client_extension/client_analytics_extension.dart @@ -96,23 +96,6 @@ extension AnalyticsClientExtension on Client { await Future.wait(makePublicFutures); } - Future _updateMyLearningAnalyticsForAllClassesImIn([ - PLocalStore? storageService, - ]) async { - try { - final List> updateFutures = []; - for (final classRoom in classesAndExchangesImIn) { - updateFutures - .add(classRoom.updateMyLearningAnalyticsForClass(storageService)); - } - await Future.wait(updateFutures); - } catch (err, s) { - if (kDebugMode) rethrow; - // debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - } - } - // Add all the users' analytics room to all the spaces the student studies in // So teachers can join them via space hierarchy // Will not always work, as there may be spaces where students don't have permission to add chats diff --git a/lib/pangea/extensions/client_extension/client_extension.dart b/lib/pangea/extensions/client_extension/client_extension.dart index c2c673dff..d23caa5de 100644 --- a/lib/pangea/extensions/client_extension/client_extension.dart +++ b/lib/pangea/extensions/client_extension/client_extension.dart @@ -11,8 +11,6 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; -import '../../utils/p_store.dart'; - part "classes_and_exchanges_extension.dart"; part "client_analytics_extension.dart"; part "general_info_extension.dart"; @@ -31,11 +29,6 @@ extension PangeaClient on Client { Future updateAnalyticsRoomVisibility() async => await _updateAnalyticsRoomVisibility(); - Future updateMyLearningAnalyticsForAllClassesImIn([ - PLocalStore? storageService, - ]) async => - await _updateMyLearningAnalyticsForAllClassesImIn(storageService); - Future addAnalyticsRoomsToAllSpaces() async => await _addAnalyticsRoomsToAllSpaces(); diff --git a/lib/pangea/extensions/client_extension/general_info_extension.dart b/lib/pangea/extensions/client_extension/general_info_extension.dart index af9700cf6..058b6f695 100644 --- a/lib/pangea/extensions/client_extension/general_info_extension.dart +++ b/lib/pangea/extensions/client_extension/general_info_extension.dart @@ -5,11 +5,7 @@ extension GeneralInfoClientExtension on Client { final List adminRoomIds = []; for (final Room adminSpace in (await _classesAndExchangesImTeaching)) { adminRoomIds.add(adminSpace.id); - final children = adminSpace.childrenAndGrandChildren; - final List adminSpaceRooms = children - .where((e) => e.roomId != null) - .map((e) => e.roomId!) - .toList(); + final List adminSpaceRooms = adminSpace.allSpaceChildRoomIds; adminRoomIds.addAll(adminSpaceRooms); } return adminRoomIds; diff --git a/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart b/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart index 4362c17d8..79b923683 100644 --- a/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/children_and_parents_extension.dart @@ -20,57 +20,6 @@ extension ChildrenAndParentsRoomExtension on Room { List get _joinedChildrenRoomIds => joinedChildren.map((child) => child.id).toList(); - List get _childrenAndGrandChildren { - if (!isSpace) return []; - final List kids = []; - for (final child in spaceChildren) { - kids.add(child); - if (child.roomId != null) { - final Room? childRoom = client.getRoomById(child.roomId!); - if (childRoom != null && childRoom.isSpace) { - kids.addAll(childRoom.spaceChildren); - } - } - } - return kids.where((element) => element.roomId != null).toList(); - } - - //this assumes that a user has been invited to all group chats in a space - //it is a janky workaround for determining whether a spacechild is a direct chat - //since the spaceChild object doesn't contain this info. this info is only accessible - //when the user has joined or been invited to the room. direct chats included in - //a space show up in spaceChildren but the user has not been invited to them. - List get _childrenAndGrandChildrenDirectChatIds { - final List nonDirectChatRoomIds = childrenAndGrandChildren - .where((child) => child.roomId != null) - .map((e) => client.getRoomById(e.roomId!)) - .where((r) => r != null && !r.isDirectChat) - .map((e) => e!.id) - .toList(); - - return childrenAndGrandChildren - .where( - (child) => - child.roomId != null && - !nonDirectChatRoomIds.contains(child.roomId), - ) - .map((e) => e.roomId) - .cast() - .toList(); - - // return childrenAndGrandChildren - // .where((element) => element.roomId != null) - // .where( - // (child) { - // final room = client.getRoomById(child.roomId!); - // return room == null || room.isDirectChat; - // }, - // ) - // .map((e) => e.roomId) - // .cast() - // .toList(); - } - Future> _getChildRooms() async { final List children = []; for (final child in spaceChildren) { @@ -145,4 +94,19 @@ extension ChildrenAndParentsRoomExtension on Room { ), ) .toList(); + + // gets all space children of a given space, down the + // space tree. + List get _allSpaceChildRoomIds { + final List childIds = []; + for (final child in spaceChildren) { + if (child.roomId == null) continue; + childIds.add(child.roomId!); + final Room? room = client.getRoomById(child.roomId!); + if (room != null && room.isSpace) { + childIds.addAll(room._allSpaceChildRoomIds); + } + } + return childIds; + } } diff --git a/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings_extension.dart b/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings_extension.dart index e71ed8f4f..04fc472f4 100644 --- a/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/class_and_exchange_settings_extension.dart @@ -62,22 +62,11 @@ extension ClassAndExchangeSettingsRoomExtension on Room { } final Event? currentPower = getState(EventTypes.RoomPowerLevels); final Map? currentPowerContent = - currentPower?.content as Map?; - if (currentPowerContent == null) { - return; - } - if (!(currentPowerContent.containsKey("events"))) { - currentPowerContent["events"] = {}; - } - final spaceChildPower = - currentPowerContent["events"][EventTypes.spaceChild]; - final studentAnalyticsPower = currentPowerContent["events"] - [PangeaEventTypes.studentAnalyticsSummary]; + currentPower?.content["events"] as Map?; + final spaceChildPower = currentPowerContent?[EventTypes.spaceChild]; - if ((spaceChildPower == null || studentAnalyticsPower == null)) { + if (spaceChildPower == null && currentPowerContent != null) { currentPowerContent["events"][EventTypes.spaceChild] = 0; - currentPowerContent["events"] - [PangeaEventTypes.studentAnalyticsSummary] = 0; await client.setRoomStateWithKey( id, diff --git a/lib/pangea/extensions/pangea_room_extension/events_extension.dart b/lib/pangea/extensions/pangea_room_extension/events_extension.dart index 30f994852..631d21478 100644 --- a/lib/pangea/extensions/pangea_room_extension/events_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/events_extension.dart @@ -117,7 +117,6 @@ extension EventsRoomExtension on Room { required String type, }) async { try { - debugPrint("creating $type child for $parentEventId"); Sentry.addBreadcrumb(Breadcrumb.fromJson(content)); if (parentEventId.contains("web")) { debugger(when: kDebugMode); @@ -312,122 +311,153 @@ extension EventsRoomExtension on Room { } } - ConstructEvent? _vocabEventLocal(String lemma) { - if (!isAnalyticsRoom) throw Exception("not an analytics room"); + // ConstructEvent? _vocabEventLocal(String lemma) { + // if (!isAnalyticsRoom) throw Exception("not an analytics room"); - final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma); + // final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma); - return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null; - } + // return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null; + // } - Future _vocabEvent( - String lemma, - ConstructType type, [ - bool makeIfNull = false, - ]) async { - try { - if (!isAnalyticsRoom) throw Exception("not an analytics room"); + // Future _vocabEvent( + // String lemma, + // ConstructType type, [ + // bool makeIfNull = false, + // ]) async { + // try { + // if (!isAnalyticsRoom) throw Exception("not an analytics room"); - ConstructEvent? localEvent = _vocabEventLocal(lemma); + // ConstructEvent? localEvent = _vocabEventLocal(lemma); - if (localEvent != null) return localEvent; + // if (localEvent != null) return localEvent; - await postLoad(); - localEvent = _vocabEventLocal(lemma); + // await postLoad(); + // localEvent = _vocabEventLocal(lemma); - if (localEvent == null && isRoomOwner && makeIfNull) { - final Event matrixEvent = await _createVocabEvent(lemma, type); - localEvent = ConstructEvent(event: matrixEvent); - } + // if (localEvent == null && isRoomOwner && makeIfNull) { + // final Event matrixEvent = await _createVocabEvent(lemma, type); + // localEvent = ConstructEvent(event: matrixEvent); + // } - return localEvent!; - } catch (err) { - debugger(when: kDebugMode); - rethrow; - } - } + // return localEvent!; + // } catch (err) { + // debugger(when: kDebugMode); + // rethrow; + // } + // } - Future> _removeEditedLemmas( - List lemmaUses, - ) async { - final List removeUses = []; - for (final use in lemmaUses) { - if (use.msgId == null) continue; - final List removeIds = await client.getEditHistory( - use.chatId, - use.msgId!, - ); - removeUses.addAll(removeIds); - } - lemmaUses.removeWhere((use) => removeUses.contains(use.msgId)); - final allEvents = await allConstructEvents; - for (final constructEvent in allEvents) { - await constructEvent.removeEdittedUses(removeUses, client); - } - return lemmaUses; - } + // Future _createVocabEvent(String lemma, ConstructType type) async { + // try { + // if (!isRoomOwner) { + // throw Exception( + // "Tried to create vocab event in room where user is not owner", + // ); + // } + // final String eventId = await client.setRoomStateWithKey( + // id, + // PangeaEventTypes.vocab, + // lemma, + // ConstructUses(lemma: lemma, type: type).toJson(), + // ); + // final Event? event = await getEventById(eventId); - Future _saveConstructUsesSameLemma( - String lemma, - ConstructType type, - List lemmaUses, { - bool isEdit = false, + // if (event == null) { + // debugger(when: kDebugMode); + // throw Exception( + // "null event after creation with eventId $eventId in _createVocabEvent", + // ); + // } + // return event; + // } catch (err, stack) { + // debugger(when: kDebugMode); + // ErrorHandler.logError(e: err, s: stack, data: powerLevels); + // rethrow; + // } + // } + + Future> myMessageEventsInChat({ + DateTime? since, }) async { - final ConstructEvent? localEvent = _vocabEventLocal(lemma); - - if (isEdit) { - lemmaUses = await removeEditedLemmas(lemmaUses); - } - - if (localEvent == null) { - await client.setRoomStateWithKey( - id, - PangeaEventTypes.vocab, - lemma, - ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(), + final List msgEvents = await getEventsBySender( + type: EventTypes.Message, + sender: client.userID!, + since: since, + ); + final Timeline timeline = await getTimeline(); + return msgEvents + .where((event) => (event.content['msgtype'] == MessageTypes.Text)) + .map((event) { + return PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: true, ); - } else { - localEvent.addAll(lemmaUses); - await updateStateEvent(localEvent.event); - } + }).toList(); } - Future> get _allConstructEvents async { - await postLoad(); - return states[PangeaEventTypes.vocab] - ?.values - .map((Event event) => ConstructEvent(event: event)) - .toList() - .cast() ?? - []; - } - - Future _createVocabEvent(String lemma, ConstructType type) async { + // 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, + int? count, + }) async { try { - if (!isRoomOwner) { - throw Exception( - "Tried to create vocab event in room where user is not owner", - ); - } - final String eventId = await client.setRoomStateWithKey( - id, - PangeaEventTypes.vocab, - lemma, - ConstructUses(lemma: lemma, type: type).toJson(), - ); - final Event? event = await getEventById(eventId); + int numberOfSearches = 0; + final Timeline timeline = await getTimeline(); - if (event == null) { - debugger(when: kDebugMode); - throw Exception( - "null event after creation with eventId $eventId in _createVocabEvent", + 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 && + !reachedEnd() && + numberOfSearches < 10) { + await timeline.requestHistory(historyCount: 100); + numberOfSearches += 1; + 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), ); } - return event; - } catch (err, stack) { + + 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); + } + + return events; + } catch (err, s) { + if (kDebugMode) rethrow; debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: stack, data: powerLevels); - rethrow; + ErrorHandler.logError(e: err, s: s); + return []; } } } 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 a203619ac..a8cc00a84 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -7,6 +7,10 @@ import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; import 'package:fluffychat/pangea/models/class_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; @@ -21,20 +25,13 @@ import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:html_unescape/html_unescape.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/utils/markdown.dart'; -import 'package:matrix/src/utils/space_child.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../../../config/app_config.dart'; import '../../constants/pangea_event_types.dart'; -import '../../enum/construct_type_enum.dart'; import '../../enum/use_type.dart'; -import '../../matrix_event_wrappers/construct_analytics_event.dart'; import '../../models/choreo_record.dart'; -import '../../models/constructs_analytics_model.dart'; import '../../models/representation_content_model.dart'; -import '../../models/student_analytics_event.dart'; -import '../../models/student_analytics_summary_model.dart'; -import '../../utils/p_store.dart'; import '../client_extension/client_extension.dart'; part "children_and_parents_extension.dart"; @@ -63,26 +60,6 @@ extension PangeaRoom on Room { Future addAnalyticsRoomsToSpace() async => await _addAnalyticsRoomsToSpace(); - Future getStudentAnalytics( - String studentId, { - bool forcedUpdate = false, - }) async => - await _getStudentAnalytics(studentId, forcedUpdate: forcedUpdate); - - Future> getClassAnalytics([ - List? studentIds, - ]) async => - await _getClassAnalytics( - studentIds, - ); - - Future updateMyLearningAnalyticsForClass([ - PLocalStore? storageService, - ]) async => - await _updateMyLearningAnalyticsForClass( - storageService, - ); - Future inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async => await _inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); @@ -93,17 +70,33 @@ extension PangeaRoom on Room { Future inviteSpaceTeachersToAnalyticsRooms() async => await _inviteSpaceTeachersToAnalyticsRooms(); + Future getLastAnalyticsEvent( + String type, + String userId, + ) async => + await _getLastAnalyticsEvent(type, userId); + + Future analyticsLastUpdated(String type, String userId) async { + return await _analyticsLastUpdated(type, userId); + } + + Future?> getAnalyticsEvents({ + required String type, + required String userId, + DateTime? since, + }) async => + await _getAnalyticsEvents(type: type, since: since, userId: userId); + + String? get madeForLang => _madeForLang; + + bool isMadeForLang(String langCode) => _isMadeForLang(langCode); + // children_and_parents List get joinedChildren => _joinedChildren; List get joinedChildrenRoomIds => _joinedChildrenRoomIds; - List get childrenAndGrandChildren => _childrenAndGrandChildren; - - List get childrenAndGrandChildrenDirectChatIds => - _childrenAndGrandChildrenDirectChatIds; - Future> getChildRooms() async => await _getChildRooms(); Future joinSpaceChild(String roomID) async => @@ -116,6 +109,8 @@ extension PangeaRoom on Room { List get pangeaSpaceParents => _pangeaSpaceParents; + List get allSpaceChildRoomIds => _allSpaceChildRoomIds; + // class_and_exchange_settings DateTime? get rulesUpdatedAt => _rulesUpdatedAt; @@ -204,29 +199,6 @@ extension PangeaRoom on Room { Future updateStateEvent(Event stateEvent) => _updateStateEvent(stateEvent); - Future vocabEvent( - String lemma, - ConstructType type, [ - bool makeIfNull = false, - ]) => - _vocabEvent(lemma, type, makeIfNull); - - Future> removeEditedLemmas( - List lemmaUses, - ) async => - await _removeEditedLemmas(lemmaUses); - - Future saveConstructUsesSameLemma( - String lemma, - ConstructType type, - List lemmaUses, { - bool isEdit = false, - }) async => - await _saveConstructUsesSameLemma(lemma, type, lemmaUses, isEdit: isEdit); - - Future> get allConstructEvents async => - await _allConstructEvents; - // room_information Future get numNonAdmins async => await _numNonAdmins; @@ -245,7 +217,7 @@ extension PangeaRoom on Room { bool get isDirectChatWithoutMe => _isDirectChatWithoutMe; - bool isMadeForLang(String langCode) => _isMadeForLang(langCode); + // bool isMadeForLang(String langCode) => _isMadeForLang(langCode); Future get isBotRoom async => await _isBotRoom; 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 b6c58a40d..756f83adf 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -123,185 +123,6 @@ extension AnalyticsRoomExtension on Room { } } - StudentAnalyticsEvent? _getStudentAnalyticsLocal(String studentId) { - if (!isSpace) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "calling getStudentAnalyticsLocal on non-space room", - s: StackTrace.current, - ); - return null; - } - - final Event? matrixEvent = getState( - PangeaEventTypes.studentAnalyticsSummary, - studentId, - ); - - return matrixEvent != null - ? StudentAnalyticsEvent(event: matrixEvent) - : null; - } - - Future _getStudentAnalytics( - String studentId, { - bool forcedUpdate = false, - }) async { - try { - if (!isSpace) { - debugger(when: kDebugMode); - throw Exception("calling getStudentAnalyticsLocal on non-space room"); - } - StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal(studentId); - - if (localEvent == null) { - await postLoad(); - localEvent = _getStudentAnalyticsLocal(studentId); - } - - if (studentId == client.userID && localEvent == null) { - final Event? matrixEvent = await _createStudentAnalyticsEvent(); - if (matrixEvent != null) { - localEvent = StudentAnalyticsEvent(event: matrixEvent); - } - } - - return localEvent; - } catch (err) { - debugger(when: kDebugMode); - rethrow; - } - } - - /// if [studentIds] is null, returns all students - Future> _getClassAnalytics([ - List? studentIds, - ]) async { - await postLoad(); - await requestParticipants(); - final List> sassFutures = []; - final List filteredIds = students - .where( - (element) => studentIds == null || studentIds.contains(element.id), - ) - .map((e) => e.id) - .toList(); - for (final id in filteredIds) { - sassFutures.add( - getStudentAnalytics( - id, - ), - ); - } - return Future.wait(sassFutures); - } - - /// if [isSpace] - /// for all child chats, call _getChatAnalyticsGlobal and merge results - /// else - /// get analytics from pangea chat server - /// do any needed conversion work - /// save RoomAnalytics object to PangeaEventTypes.analyticsSummary event - Future _createStudentAnalyticsEvent() async { - try { - await postLoad(); - if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { - ErrorHandler.logError( - m: "null powerLevels in createStudentAnalytics", - s: StackTrace.current, - ); - return null; - } - if (client.userID == null) { - debugger(when: kDebugMode); - throw Exception("null userId in createStudentAnalytics"); - } - - final String eventId = await client.setRoomStateWithKey( - id, - PangeaEventTypes.studentAnalyticsSummary, - client.userID!, - StudentAnalyticsSummary( - // studentId: client.userID!, - lastUpdated: DateTime.now(), - messages: [], - ).toJson(), - ); - final Event? event = await getEventById(eventId); - - if (event == null) { - debugger(when: kDebugMode); - throw Exception( - "null event after creation with eventId $eventId in createStudentAnalytics", - ); - } - return event; - } catch (err, stack) { - ErrorHandler.logError(e: err, s: stack, data: powerLevels); - return null; - } - } - - /// for each chat in class - /// get timeline back to january 15 - /// get messages - /// discard timeline - /// save messages to StudentAnalyticsSummary - Future _updateMyLearningAnalyticsForClass([ - PLocalStore? storageService, - ]) async { - try { - final String migratedAnalyticsKey = - "MIGRATED_ANALYTICS_KEY${id.localpart}"; - - if (storageService?.read( - migratedAnalyticsKey, - local: true, - ) ?? - false) return; - - if (!isPangeaClass && !isExchange) { - throw Exception( - "In updateMyLearningAnalyticsForClass with room that is not not a class", - ); - } - - if (client.userID == null) { - debugger(when: kDebugMode); - return; - } - - final StudentAnalyticsEvent? myAnalEvent = - await getStudentAnalytics(client.userID!); - - if (myAnalEvent == null) { - debugPrint("null analytcs event for $id"); - if (pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) { - // debugger(when: kDebugMode); - } - return; - } - - final updateMessages = await _messageListForAllChildChats; - updateMessages.removeWhere( - (element) => myAnalEvent.content.messages.any( - (e) => e.eventId == element.eventId, - ), - ); - myAnalEvent.bulkUpdate(updateMessages); - - await storageService?.save( - migratedAnalyticsKey, - true, - local: true, - ); - } catch (err, s) { - if (kDebugMode) rethrow; - // debugger(when: kDebugMode); - ErrorHandler.logError(e: err, s: s); - } - } - // invite teachers of 1 space to 1 analytics room Future _inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async { if (!isSpace) { @@ -373,4 +194,67 @@ extension AnalyticsRoomExtension on Room { await inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); } } + + Future _getLastAnalyticsEvent( + String type, + String userId, + ) async { + final List events = await getEventsBySender( + type: type, + sender: userId, + count: 1, + ); + if (events.isEmpty) return null; + final Event event = events.first; + AnalyticsEvent? analyticsEvent; + switch (type) { + case PangeaEventTypes.summaryAnalytics: + analyticsEvent = SummaryAnalyticsEvent(event: event); + case PangeaEventTypes.construct: + analyticsEvent = ConstructAnalyticsEvent(event: event); + } + return analyticsEvent; + } + + Future _analyticsLastUpdated(String type, String userId) async { + final lastEvent = await _getLastAnalyticsEvent(type, userId); + return lastEvent?.event.originServerTs; + } + + Future?> _getAnalyticsEvents({ + required String type, + required String userId, + DateTime? since, + }) async { + final List events = await getEventsBySender( + type: type, + sender: userId, + since: since, + ); + final List analyticsEvents = []; + for (final Event event in events) { + switch (type) { + case PangeaEventTypes.summaryAnalytics: + analyticsEvents.add(SummaryAnalyticsEvent(event: event)); + break; + case PangeaEventTypes.construct: + analyticsEvents.add(ConstructAnalyticsEvent(event: event)); + break; + } + } + + return analyticsEvents; + } + + String? get _madeForLang { + final creationContent = getState(EventTypes.RoomCreate)?.content; + return creationContent?.tryGet(ModelKey.langCode) ?? + creationContent?.tryGet(ModelKey.oldLangCode); + } + + bool _isMadeForLang(String langCode) { + final creationContent = getState(EventTypes.RoomCreate)?.content; + return creationContent?.tryGet(ModelKey.langCode) == langCode || + creationContent?.tryGet(ModelKey.oldLangCode) == langCode; + } } diff --git a/lib/pangea/extensions/pangea_room_extension/room_information_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_information_extension.dart index b4f390ffb..213d3c4ce 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_information_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_information_extension.dart @@ -45,11 +45,11 @@ extension RoomInformationRoomExtension on Room { bool get _isDirectChatWithoutMe => isDirectChat && !getParticipants().any((e) => e.id == client.userID); - bool _isMadeForLang(String langCode) { - final creationContent = getState(EventTypes.RoomCreate)?.content; - return creationContent?.tryGet(ModelKey.langCode) == langCode || - creationContent?.tryGet(ModelKey.oldLangCode) == langCode; - } + // bool _isMadeForLang(String langCode) { + // final creationContent = getState(EventTypes.RoomCreate)?.content; + // return creationContent?.tryGet(ModelKey.langCode) == langCode || + // creationContent?.tryGet(ModelKey.oldLangCode) == langCode; + // } Future get _isBotRoom async { final List participants = await requestParticipants(); diff --git a/lib/pangea/matrix_event_wrappers/construct_analytics_event.dart b/lib/pangea/matrix_event_wrappers/construct_analytics_event.dart index 176d84bbd..0dd820676 100644 --- a/lib/pangea/matrix_event_wrappers/construct_analytics_event.dart +++ b/lib/pangea/matrix_event_wrappers/construct_analytics_event.dart @@ -1,53 +1,60 @@ -import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; -import 'package:matrix/matrix.dart'; +// import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; +// import 'package:flutter/material.dart'; +// import 'package:matrix/matrix.dart'; -import '../constants/pangea_event_types.dart'; +// import '../constants/pangea_event_types.dart'; -class ConstructEvent { - late Event _event; - ConstructUses? _contentCache; +// class ConstructEvent { +// late Event _event; +// ConstructUses? _contentCache; - ConstructEvent({required Event event}) { - if (event.type != PangeaEventTypes.vocab) { - throw Exception( - "${event.type} should not be used to make a StudentAnalyticsEvent", - ); - } - _event = event; - } +// ConstructEvent({required Event event}) { +// if (event.type != PangeaEventTypes.vocab) { +// throw Exception( +// "${event.type} should not be used to make a StudentAnalyticsEvent", +// ); +// } +// _event = event; +// } - Event get event => _event; +// Event get event => _event; - ConstructUses get content { - _contentCache ??= ConstructUses.fromJson(event.content); - if (_contentCache!.lemma.isEmpty) { - _contentCache!.lemma = event.stateKey!; - } - return _contentCache!; - } +// ConstructUses get content { +// _contentCache ??= ConstructUses.fromJson(event.content); +// if (_contentCache!.lemma.isEmpty) { +// _contentCache!.lemma = event.stateKey!; +// } +// return _contentCache!; +// } - void addAll(List uses) { - content.uses.addAll(uses); - event.content = content.toJson(); - } +// void addAll(List uses) { +// for (final use in uses) { +// if (content.uses.any((element) => element.id == use.id)) { +// continue; +// } +// debugPrint("${use.toJson()}"); +// content.uses.add(use); +// } +// event.content = content.toJson(); +// } - Future removeEdittedUses( - List removeIds, - Client client, - ) async { - _contentCache ??= ConstructUses.fromJson(event.content); - if (_contentCache == null || _event.stateKey == null) return; - final previousLength = _contentCache!.uses.length; - _contentCache!.uses.removeWhere( - (element) => removeIds.contains(element.msgId), - ); - if (previousLength > _contentCache!.uses.length) { - await client.setRoomStateWithKey( - _event.room.id, - _event.type, - _event.stateKey!, - _contentCache!.toJson(), - ); - } - } -} +// Future removeEdittedUses( +// List removeIds, +// Client client, +// ) async { +// _contentCache ??= ConstructUses.fromJson(event.content); +// if (_contentCache == null || _event.stateKey == null) return; +// final previousLength = _contentCache!.uses.length; +// _contentCache!.uses.removeWhere( +// (element) => removeIds.contains(element.msgId), +// ); +// if (previousLength > _contentCache!.uses.length) { +// await client.setRoomStateWithKey( +// _event.room.id, +// _event.type, +// _event.stateKey!, +// _contentCache!.toJson(), +// ); +// } +// } +// } diff --git a/lib/pangea/models/analytics/analytics_event.dart b/lib/pangea/models/analytics/analytics_event.dart new file mode 100644 index 000000000..2453e62ef --- /dev/null +++ b/lib/pangea/models/analytics/analytics_event.dart @@ -0,0 +1,59 @@ +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart'; +import 'package:matrix/matrix.dart'; + +// superclass for all analytics events +abstract class AnalyticsEvent { + late Event _event; + AnalyticsModel? contentCache; + + AnalyticsEvent({required Event event}) { + _event = event; + } + + Event get event => _event; + + AnalyticsModel get content { + switch (_event.type) { + case PangeaEventTypes.summaryAnalytics: + contentCache ??= SummaryAnalyticsModel.fromJson(event.content); + break; + case PangeaEventTypes.construct: + contentCache ??= ConstructAnalyticsModel.fromJson(event.content); + break; + } + return contentCache!; + } + + static List analyticsEventTypes = [ + PangeaEventTypes.summaryAnalytics, + PangeaEventTypes.construct, + ]; + + static Future sendEvent( + Room analyticsRoom, + String type, + List analyticsContent, + ) async { + String? eventId; + switch (type) { + case PangeaEventTypes.summaryAnalytics: + eventId = await SummaryAnalyticsEvent.sendSummaryAnalyticsEvent( + analyticsRoom, + analyticsContent.cast(), + ); + break; + case PangeaEventTypes.construct: + eventId = await ConstructAnalyticsEvent.sendConstructsEvent( + analyticsRoom, + analyticsContent.cast(), + ); + break; + } + return eventId; + } +} diff --git a/lib/pangea/models/analytics/analytics_model.dart b/lib/pangea/models/analytics/analytics_model.dart new file mode 100644 index 000000000..bdb3bc6d5 --- /dev/null +++ b/lib/pangea/models/analytics/analytics_model.dart @@ -0,0 +1,19 @@ +import 'package:fluffychat/pangea/constants/pangea_event_types.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/analytics/summary_analytics_model.dart'; + +abstract class AnalyticsModel { + static List formatAnalyticsContent( + List recentMsgs, + String type, + ) { + switch (type) { + case PangeaEventTypes.summaryAnalytics: + return SummaryAnalyticsModel.formatSummaryContent(recentMsgs); + case PangeaEventTypes.construct: + return ConstructAnalyticsModel.formatConstructsContent(recentMsgs); + } + return []; + } +} diff --git a/lib/pangea/models/chart_analytics_model.dart b/lib/pangea/models/analytics/chart_analytics_model.dart similarity index 91% rename from lib/pangea/models/chart_analytics_model.dart rename to lib/pangea/models/analytics/chart_analytics_model.dart index af06a0958..7430ede2f 100644 --- a/lib/pangea/models/chart_analytics_model.dart +++ b/lib/pangea/models/analytics/chart_analytics_model.dart @@ -1,10 +1,10 @@ import 'dart:developer'; +import 'package:fluffychat/pangea/enum/time_span.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart'; import 'package:flutter/foundation.dart'; -import 'package:fluffychat/pangea/enum/time_span.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; -import '../enum/use_type.dart'; +import '../../enum/use_type.dart'; class TimeSeriesTotals { int ta; @@ -137,4 +137,13 @@ class ChartAnalyticsModel { } timeSeries = intervals.values.toList().reversed.toList(); } + + DateTime? get lastMessageTime { + if (msgs.isEmpty) { + return null; + } + return msgs.map((msg) => msg.time).reduce( + (compare, recent) => compare.isAfter(recent) ? compare : recent, + ); + } } diff --git a/lib/pangea/models/analytics/constructs_event.dart b/lib/pangea/models/analytics/constructs_event.dart new file mode 100644 index 000000000..c2930faba --- /dev/null +++ b/lib/pangea/models/analytics/constructs_event.dart @@ -0,0 +1,36 @@ +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:matrix/matrix.dart'; + +import '../../constants/pangea_event_types.dart'; + +class ConstructAnalyticsEvent extends AnalyticsEvent { + ConstructAnalyticsEvent({required Event event}) : super(event: event) { + if (event.type != PangeaEventTypes.construct) { + throw Exception( + "${event.type} should not be used to make a ConstructAnalyticsEvent", + ); + } + } + + @override + ConstructAnalyticsModel get content { + contentCache ??= ConstructAnalyticsModel.fromJson(event.content); + return contentCache as ConstructAnalyticsModel; + } + + static Future sendConstructsEvent( + Room analyticsRoom, + List uses, + ) async { + final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel( + uses: uses, + ); + + final String? eventId = await analyticsRoom.sendEvent( + constructsModel.toJson(), + type: PangeaEventTypes.construct, + ); + return eventId; + } +} diff --git a/lib/pangea/models/constructs_analytics_model.dart b/lib/pangea/models/analytics/constructs_model.dart similarity index 55% rename from lib/pangea/models/constructs_analytics_model.dart rename to lib/pangea/models/analytics/constructs_model.dart index a62145485..18c6d3d5a 100644 --- a/lib/pangea/models/constructs_analytics_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -1,69 +1,105 @@ import 'dart:developer'; -import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import '../enum/construct_type_enum.dart'; - -class ConstructUses { - String lemma; - ConstructType type; +import '../../enum/construct_type_enum.dart'; +class ConstructAnalyticsModel extends AnalyticsModel { List uses; - //PTODO - how to incorporate semantic similarity score into this? - - //PTODO - add variables for saving requests for - // 1) definitions - // 2) translations - // 3) examples??? (gpt suggested) - - ConstructUses({ - required this.lemma, - required this.type, + ConstructAnalyticsModel({ this.uses = const [], }); - factory ConstructUses.fromJson(Map json) { - // try { - debugger( - when: - kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null), + static const _usesKey = "uses"; + + factory ConstructAnalyticsModel.fromJson(Map json) { + final List uses = []; + if (json[_usesKey] is List) { + // This is the new format + uses.addAll( + json[_usesKey] + .map((use) => OneConstructUse.fromJson(use)) + .cast() + .toList(), + ); + } else { + // This is the old format. No data on production should be + // structured this way, but it's useful for testing. + try { + final useValues = (json[_usesKey] as Map).values; + for (final useValue in useValues) { + final lemma = useValue['lemma']; + final lemmaUses = useValue[_usesKey]; + for (final useData in lemmaUses) { + final use = OneConstructUse( + useType: ConstructUseType.ga, + chatId: useData["chatId"], + timeStamp: DateTime.parse(useData["timeStamp"]), + lemma: lemma, + form: useData["form"], + msgId: useData["msgId"], + constructType: ConstructType.grammar, + ); + uses.add(use); + } + } + } catch (err, s) { + debugPrint("Error parsing ConstructAnalyticsModel"); + ErrorHandler.logError( + e: err, + s: s, + m: "Error parsing ConstructAnalyticsModel", + ); + debugger(when: kDebugMode); + } + } + return ConstructAnalyticsModel( + uses: uses, ); - return ConstructUses( - lemma: json[ModelKey.lemma], - uses: (json['uses'] as Iterable) - .map( - (use) => use != null ? OneConstructUse.fromJson(use) : null, - ) - .where((element) => element != null) - .cast() - .toList(), - type: ConstructTypeUtil.fromString(json['type']), - ); - // } catch (err) { - // debugger(when: kDebugMode); - // rethrow; - // } } toJson() { return { - ModelKey.lemma: lemma, - 'uses': uses.map((use) => use.toJson()).toList(), - 'type': type.string, + _usesKey: uses.map((use) => use.toJson()).toList(), }; } - void addUsesByUseType(List uses) { - for (final use in uses) { - if (use.lemma != lemma) { - throw Exception('lemma mismatch'); - } - uses.add(use); + static List formatConstructsContent( + List recentMsgs, + ) { + final List filtered = List.from(recentMsgs); + final List uses = []; + + for (final msg in filtered) { + if (msg.originalSent?.choreo == null) continue; + uses.addAll( + msg.originalSent!.choreo!.toGrammarConstructUse( + msg.eventId, + msg.room.id, + msg.originServerTs, + ), + ); + + final List? tokens = msg.originalSent?.tokens; + if (tokens == null) continue; + uses.addAll( + msg.originalSent!.choreo!.toVocabUse( + tokens, + msg.room.id, + msg.eventId, + msg.originServerTs, + ), + ); } + + return uses; } } @@ -153,6 +189,7 @@ class OneConstructUse { String chatId; String? msgId; DateTime timeStamp; + String? id; OneConstructUse({ required this.useType, @@ -162,6 +199,7 @@ class OneConstructUse { required this.form, required this.msgId, required this.constructType, + this.id, }); factory OneConstructUse.fromJson(Map json) { @@ -176,10 +214,11 @@ class OneConstructUse { constructType: json['constructType'] != null ? ConstructTypeUtil.fromString(json['constructType']) : null, + id: json['id'], ); } - Map toJson([bool condensed = true]) { + Map toJson([bool condensed = false]) { final Map data = { 'useType': useType.string, 'chatId': chatId, @@ -191,6 +230,7 @@ class OneConstructUse { if (!condensed && constructType != null) { data['constructType'] = constructType!.string; } + if (id != null) data['id'] = id; return data; } @@ -205,3 +245,15 @@ class OneConstructUse { return room.getEventById(msgId!); } } + +class ConstructUses { + final List uses; + final ConstructType constructType; + final String lemma; + + ConstructUses({ + required this.uses, + required this.constructType, + required this.lemma, + }); +} diff --git a/lib/pangea/models/analytics/summary_analytics_event.dart b/lib/pangea/models/analytics/summary_analytics_event.dart new file mode 100644 index 000000000..e7034eaa4 --- /dev/null +++ b/lib/pangea/models/analytics/summary_analytics_event.dart @@ -0,0 +1,35 @@ +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/summary_analytics_model.dart'; +import 'package:matrix/matrix.dart'; + +import '../../constants/pangea_event_types.dart'; + +class SummaryAnalyticsEvent extends AnalyticsEvent { + SummaryAnalyticsEvent({required Event event}) : super(event: event) { + if (event.type != PangeaEventTypes.summaryAnalytics) { + throw Exception( + "${event.type} should not be used to make a SummaryAnalyticsEvent", + ); + } + } + + @override + SummaryAnalyticsModel get content { + contentCache ??= SummaryAnalyticsModel.fromJson(event.content); + return contentCache as SummaryAnalyticsModel; + } + + static Future sendSummaryAnalyticsEvent( + Room analyticsRoom, + List records, + ) async { + final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel( + messages: records, + ); + final String? eventId = await analyticsRoom.sendEvent( + analyticsModel.toJson(), + type: PangeaEventTypes.summaryAnalytics, + ); + return eventId; + } +} diff --git a/lib/pangea/models/student_analytics_summary_model.dart b/lib/pangea/models/analytics/summary_analytics_model.dart similarity index 70% rename from lib/pangea/models/student_analytics_summary_model.dart rename to lib/pangea/models/analytics/summary_analytics_model.dart index 69d237ea9..b09d0a870 100644 --- a/lib/pangea/models/student_analytics_summary_model.dart +++ b/lib/pangea/models/analytics/summary_analytics_model.dart @@ -1,10 +1,64 @@ import 'dart:convert'; +import 'package:fluffychat/pangea/enum/use_type.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; -import 'package:matrix/matrix.dart'; -import '../enum/use_type.dart'; +class SummaryAnalyticsModel extends AnalyticsModel { + late List _messages; + + SummaryAnalyticsModel({ + required List messages, + }) { + _messages = messages; + } + + List get messages => _messages; + + static const _messagesKey = "msgs"; + + Map toJson() => { + _messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()), + }; + + factory SummaryAnalyticsModel.fromJson(json) { + List savedMessages = []; + try { + savedMessages = json[_messagesKey] != null + ? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable) + .map((e) => RecentMessageRecord.fromJson(e)) + .toList() + .cast() + : []; + } catch (err, stack) { + if (kDebugMode) rethrow; + ErrorHandler.logError(e: err, s: stack); + } + return SummaryAnalyticsModel( + messages: savedMessages, + ); + } + + static List formatSummaryContent( + List recentMsgs, + ) { + final List filtered = List.from(recentMsgs); + final List records = filtered + .map( + (msg) => RecentMessageRecord( + eventId: msg.eventId, + chatId: msg.room.id, + useType: msg.useType, + time: msg.originServerTs, + ), + ) + .toList(); + + return records; + } +} class RecentMessageRecord { String eventId; @@ -55,62 +109,3 @@ class RecentMessageRecord { static const _typeOfUseKey = "typ"; static const _timeKey = "t"; } - -class StudentAnalyticsSummary { - late List _messages; - DateTime lastUpdated; - - StudentAnalyticsSummary({ - required List messages, - required this.lastUpdated, - }) { - _messages = messages; - } - - void addAll(List msgs) { - for (final msg in msgs) { - if (_messages.any((element) => element.eventId == msg.eventId)) { - ErrorHandler.logError( - m: "adding message twice in StudentAnalyticsSummary.add", - ); - } else { - _messages.add(msg); - } - } - } - - void removeEdittedMessages(Client client, List removeEventIds) { - _messages.removeWhere( - (element) => removeEventIds.contains(element.eventId), - ); - } - - List get messages => _messages; - - static const _messagesKey = "msgs"; - static const _lastUpdatedKey = "lupt"; - - Map toJson() => { - _messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()), - _lastUpdatedKey: lastUpdated.toIso8601String(), - }; - - factory StudentAnalyticsSummary.fromJson(json) { - List savedMessages = []; - try { - savedMessages = json[_messagesKey] != null - ? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable) - .map((e) => RecentMessageRecord.fromJson(e)) - .toList() - .cast() - : []; - } catch (err, stack) { - if (kDebugMode) rethrow; - ErrorHandler.logError(e: err, s: stack); - } - return StudentAnalyticsSummary( - messages: savedMessages, - lastUpdated: DateTime.parse(json[_lastUpdatedKey]), - ); - } -} diff --git a/lib/pangea/models/analytics_model_old.dart b/lib/pangea/models/analytics_model_old.dart deleted file mode 100644 index 8dc5159da..000000000 --- a/lib/pangea/models/analytics_model_old.dart +++ /dev/null @@ -1,100 +0,0 @@ -// import 'dart:convert'; - -// class UserTimeSeriesInterval { -// String? userId; -// int? taTotal; -// int? gaTotal; -// int? waTotal; - -// UserTimeSeriesInterval({ -// required this.userId, -// required this.taTotal, -// required this.gaTotal, -// required this.waTotal, -// }); - -// Map toJson() => -// {"usr": userId, "ta": taTotal, "ga": gaTotal, "wa": waTotal}; - -// factory UserTimeSeriesInterval.fromJson(json) => UserTimeSeriesInterval( -// userId: json["usr"], -// taTotal: json["ta"], -// gaTotal: json["ga"], -// waTotal: json["wa"], -// ); -// } - -// class TimeSeriesInterval { -// DateTime start; -// DateTime end; -// List users; - -// TimeSeriesInterval({ -// required this.start, -// required this.end, -// required this.users, -// }); - -// Map toJson() => { -// "strt": start, -// "end": end, -// "usrs": jsonEncode(users.map((e) => e.toJson()).toList()) -// }; - -// factory TimeSeriesInterval.fromJson(json) => TimeSeriesInterval( -// start: json["strt"], -// end: json["end"], -// users: ((jsonDecode(json["usrs"]) as Iterable) -// .map((e) => UserTimeSeriesInterval.fromJson(e)) -// .toList() -// .cast()), -// ); -// } - -// class RoomAnalyticsSummary { -// List monthlyTotalsForAllTime; -// List dailyTotalsForLast30Days; -// List hourlyTotalsForLast24Hours; - -// DateTime? updatedAt; - -// RoomAnalyticsSummary({ -// required this.monthlyTotalsForAllTime, -// required this.dailyTotalsForLast30Days, -// required this.hourlyTotalsForLast24Hours, -// }); - -// Map toJson() => { -// "mnths": -// jsonEncode(monthlyTotalsForAllTime.map((e) => e.toJson()).toList()), -// "dys": jsonEncode( -// dailyTotalsForLast30Days.map((e) => e.toJson()).toList()), -// "hrs": jsonEncode( -// hourlyTotalsForLast24Hours.map((e) => e.toJson()).toList()), -// }; - -// factory RoomAnalyticsSummary.fromJson(json) => RoomAnalyticsSummary( -// monthlyTotalsForAllTime: (jsonDecode(json["mnths"]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// dailyTotalsForLast30Days: (jsonDecode(json["dys"]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// hourlyTotalsForLast24Hours: (jsonDecode(json["hrs"]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// ); -// } - -// class UserDirectChatAnalyticsSummary { -// // directChatRoomIds and analytics for those rooms -// // updated by user; -// Map? directChatSummaries; - -// Map toJson() => {}; -// } - -// // maybe search how to do date ranges in dart diff --git a/lib/pangea/models/analytics_model_older.dart b/lib/pangea/models/analytics_model_older.dart deleted file mode 100644 index 2ee817f0b..000000000 --- a/lib/pangea/models/analytics_model_older.dart +++ /dev/null @@ -1,124 +0,0 @@ -// import 'dart:convert'; - -// class ChatTimeSeriesInterval { -// String? chatId; -// int? taTotal; -// int? gaTotal; -// int? waTotal; - -// ChatTimeSeriesInterval({ -// required this.chatId, -// required this.taTotal, -// required this.gaTotal, -// required this.waTotal, -// }); - -// Map toJson() => -// {"id": chatId, "ta": taTotal, "ga": gaTotal, "wa": waTotal}; - -// factory ChatTimeSeriesInterval.fromJson(json) => ChatTimeSeriesInterval( -// chatId: json["id"], -// taTotal: json["ta"], -// gaTotal: json["ga"], -// waTotal: json["wa"], -// ); -// } - -// class TimeSeriesInterval { -// DateTime start; -// DateTime end; -// List chats; - -// TimeSeriesInterval({ -// required this.start, -// required this.end, -// required this.chats, -// }); - -// Map toJson() => { -// "strt": start, -// "end": end, -// "usrs": jsonEncode(chats.map((e) => e.toJson()).toList()) -// }; - -// factory TimeSeriesInterval.fromJson(json) => TimeSeriesInterval( -// start: DateTime(json["strt"]), -// end: DateTime(json["end"]), -// chats: ((jsonDecode(json["usrs"]) as Iterable) -// .map((e) => ChatTimeSeriesInterval.fromJson(e)) -// .toList() -// .cast()), -// ); -// } - -// // class RecentMessageRecord { -// // String eventId; -// // String typeOfUse; -// // String time; -// // } - -// class StudentAnalyticsSummary { -// /// event statekey = studentId -// // String studentId; - -// List monthlyTotalsForAllTime; -// List dailyTotalsForLast30Days; -// List hourlyTotalsForLast24Hours; - -// // List messages; - -// DateTime lastLogin; -// DateTime lastMessage; - -// DateTime lastUpdated; - -// StudentAnalyticsSummary({ -// // required this.studentId, -// required this.monthlyTotalsForAllTime, -// required this.dailyTotalsForLast30Days, -// required this.hourlyTotalsForLast24Hours, -// required this.lastLogin, -// required this.lastMessage, -// required this.lastUpdated, -// }); - -// // static const _studentIdKey = 'usr'; -// static const _monthKey = "mnths"; -// static const _dayKey = "dys"; -// static const _hoursKey = "hrs"; -// static const _lastLoginKey = "lgn"; -// static const _lastMessageKey = "msg"; -// static const _lastUpdated = "lupt"; - -// Map toJson() => { -// _monthKey: -// jsonEncode(monthlyTotalsForAllTime.map((e) => e.toJson()).toList()), -// _dayKey: jsonEncode( -// dailyTotalsForLast30Days.map((e) => e.toJson()).toList()), -// _hoursKey: jsonEncode( -// hourlyTotalsForLast24Hours.map((e) => e.toJson()).toList()), -// // _studentIdKey: studentId, -// _lastLoginKey: lastLogin.toIso8601String(), -// _lastMessageKey: lastMessage.toIso8601String(), -// _lastUpdated: lastUpdated.toIso8601String() -// }; - -// factory StudentAnalyticsSummary.fromJson(json) => StudentAnalyticsSummary( -// // studentId: json[_studentIdKey], -// monthlyTotalsForAllTime: (jsonDecode(json[_monthKey]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// dailyTotalsForLast30Days: (jsonDecode(json[_dayKey]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// hourlyTotalsForLast24Hours: (jsonDecode(json[_hoursKey]) as Iterable) -// .map((e) => TimeSeriesInterval.fromJson(e)) -// .toList() -// .cast(), -// lastLogin: DateTime(json[_lastLoginKey]), -// lastUpdated: DateTime(json[_lastLoginKey]), -// lastMessage: DateTime(json[_lastMessageKey]), -// ); -// } diff --git a/lib/pangea/models/analytics_model_oldest.dart b/lib/pangea/models/analytics_model_oldest.dart deleted file mode 100644 index f075c1fe4..000000000 --- a/lib/pangea/models/analytics_model_oldest.dart +++ /dev/null @@ -1,77 +0,0 @@ -// import 'dart:convert'; - -// class BaseDataModel { -// late int spanTotal; -// late int spanIT; -// late int spanIGC; -// late int spanDirect; - -// BaseDataModel(Map json) { -// fromJson(json); -// } - -// fromJson(Map json) { -// spanTotal = json["total"]; -// spanIT = json["it"]; -// spanIGC = json["igc"]; -// spanDirect = json["direct"]; -// } -// } - -// class TimeSeriesInterval extends BaseDataModel { -// //note: always in UTC -// late DateTime start; -// late DateTime end; - -// TimeSeriesInterval(Map json) : super(json) { -// fromJsonTimeSeriesInterval(json); -// } - -// fromJsonTimeSeriesInterval(Map json) { -// start = DateTime.parse(json["start"]); -// end = DateTime.parse(json["end"]); -// } -// } - -// class chartAnalytics extends BaseDataModel { -// late String id; -// late int allTotal; -// late int allIT; -// late int allIGC; -// late int allDirect; -// late String timeSpan; -// late DateTime fetchedAt; -// late List? chatIds; -// late List? userIds; -// late List? classIds; -// late List timeSeries; - -// chartAnalytics(Map json) : super(json) { -// fromJsonchartAnalytics(json); -// fetchedAt = DateTime.now(); -// } - -// fromJsonchartAnalytics(Map json) { -// id = json["id"]; -// timeSpan = json["timespan"]; -// allTotal = json["alltime"]["total"]; -// allIT = json["alltime"]["it"]; -// allIGC = json["alltime"]["igc"]; -// allDirect = json["alltime"]["direct"]; -// timeSeries = (json["timeseries"] as Iterable) -// .map( -// (timeSeriesJsonEntry) => TimeSeriesInterval(timeSeriesJsonEntry), -// ) -// .toList() -// .cast(); -// chatIds = json["chats"] != null && json["chats"] != [] -// ? (json["chats"] as List).cast() -// : null; -// userIds = json["users"] != null && json["userIds"] != [] -// ? (json["users"] as List).cast() -// : null; -// classIds = json["classes"] != null && json["classes"] != [] -// ? (json["classes"] as List).cast() -// : null; -// } -// } diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index d3612f8f4..3422a76a2 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -1,10 +1,11 @@ import 'dart:convert'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; + import '../constants/choreo_constants.dart'; import '../enum/construct_type_enum.dart'; -import 'constructs_analytics_model.dart'; import 'it_step.dart'; import 'lemma.dart'; @@ -126,6 +127,7 @@ class ChoreoRecord { List tokens, String chatId, String msgId, + DateTime timestamp, ) { final List uses = []; final DateTime now = DateTime.now(); @@ -140,7 +142,7 @@ class ChoreoRecord { OneConstructUse( useType: type, chatId: chatId, - timeStamp: now, + timeStamp: timestamp, lemma: lemma.text, form: lemma.form, msgId: msgId, @@ -210,9 +212,12 @@ class ChoreoRecord { return uses; } - List toGrammarConstructUse(String msgId, String chatId) { + List toGrammarConstructUse( + String msgId, + String chatId, + DateTime timestamp, + ) { final List uses = []; - final DateTime now = DateTime.now(); for (final step in choreoSteps) { if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted) { final String name = step.acceptedOrIgnoredMatch!.match.rule?.id ?? @@ -222,11 +227,12 @@ class ChoreoRecord { OneConstructUse( useType: ConstructUseType.ga, chatId: chatId, - timeStamp: now, + timeStamp: timestamp, lemma: name, form: name, msgId: msgId, constructType: ConstructType.grammar, + id: "${msgId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}", ), ); } diff --git a/lib/pangea/models/class_analytics_model.dart b/lib/pangea/models/class_analytics_model.dart deleted file mode 100644 index ba7f642ce..000000000 --- a/lib/pangea/models/class_analytics_model.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:intl/intl.dart'; - -class ClassAnalyticsModel { - ClassAnalyticsModel(); - late final Null classId; - late final List userIds; - late final List analytics; - get tableView {} - ClassAnalyticsModel.fromJson(Map json) { - classId = null; - userIds = List.castFrom(json['user_ids']); - analytics = - List.from(json['analytics']).map((e) => Analytics.fromJson(e)).toList(); - } - - Map toJson() { - final data = {}; - data['class_id'] = classId; - data['user_ids'] = userIds; - data['analytics'] = analytics.map((e) => e.toJson()).toList(); - return data; - } -} - -class Analytics { - Analytics({ - required this.title, - required this.section, - }); - late final String title; - late final List
section; - - Analytics.fromJson(Map json) { - title = json['title']; - section = - List.from(json['section']).map((e) => Section.fromJson(e)).toList(); - } - - Map toJson() { - final data = {}; - data['title'] = title; - data['section'] = section.map((e) => e.toJson()).toList(); - return data; - } -} - -class Section { - Section({ - required this.title, - required this.classTotal, - required this.data, - }); - late final String title; - late final String classTotal; - late final List data; - - Section.fromJson(Map json) { - title = json['title']; - classTotal = json['class_total']; - data = List.from(json['data']).map((e) => Data.fromJson(e)).toList(); - } - - Map toJson() { - final data = {}; - data['title'] = title; - data['class_total'] = classTotal; - (data['data'] as List).map((item) => Data.fromJson(item)).toList(); - return data; - } -} - -class Data { - Data(); - set value(String val) => _value = val; - String get value { - if (value_type == 'date') { - return DateFormat('yyyy/M/dd hh:mm a') - .format(DateTime.parse(_value).toLocal()) - .toString(); - } - return _value; - } - - late final String userId; - late final String _value; - late final String value_type; - Data.fromJson(Map json) { - userId = json['user_id']; - _value = json['value']; - value_type = json['value_type']; - } - - Map toJson() { - final data = {}; - data['user_id'] = userId; - data['value'] = _value; - data['value_type'] = value_type; - return data; - } -} diff --git a/lib/pangea/models/headwords.dart b/lib/pangea/models/headwords.dart index 3586253b8..497381fa1 100644 --- a/lib/pangea/models/headwords.dart +++ b/lib/pangea/models/headwords.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:developer'; -import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; diff --git a/lib/pangea/models/student_analytics_event.dart b/lib/pangea/models/student_analytics_event.dart deleted file mode 100644 index cb2b7de0e..000000000 --- a/lib/pangea/models/student_analytics_event.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:developer'; - -import 'package:fluffychat/pangea/constants/class_default_values.dart'; -import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_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'; -import 'chart_analytics_model.dart'; - -class StudentAnalyticsEvent { - late Event _event; - StudentAnalyticsSummary? _contentCache; - List _messagesToSave = []; - - StudentAnalyticsEvent({required Event event}) { - if (event.type != PangeaEventTypes.studentAnalyticsSummary) { - throw Exception( - "${event.type} should not be used to make a StudentAnalyticsEvent", - ); - } - _event = event; - if (!classRoom.isSpace) { - throw Exception( - "non-class room should not be used to make a StudentAnalyticsEvent", - ); - } - _event = event; - - _messagesToSave = []; - } - - Room get classRoom => _event.room; - - Event get event => _event; - - StudentAnalyticsSummary get content { - _contentCache ??= StudentAnalyticsSummary.fromJson(event.content); - return _contentCache!; - } - - Future removeEdittedMessages( - RecentMessageRecord message, - ) async { - final List removeIds = await classRoom.client.getEditHistory( - message.chatId, - message.eventId, - ); - if (removeIds.isEmpty) return; - _messagesToSave.removeWhere( - (msg) => removeIds.any((e) => e == msg.eventId), - ); - content.removeEdittedMessages( - classRoom.client, - removeIds, - ); - } - - Future handleNewMessage( - RecentMessageRecord message, { - isEdit = false, - }) async { - if (classRoom.client.userID != _event.stateKey) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not be in handleNewMessage ${classRoom.client.userID} != ${_event.stateKey}", - ); - return; - } - - if (isEdit) { - await removeEdittedMessages(message); - } - _addMessage(message); - - if (DateTime.now().difference(content.lastUpdated).inMinutes > - ClassDefaultValues.minutesDelayToUpdateMyAnalytics) { - _updateStudentAnalytics(); - } - } - - Future bulkUpdate(List messages) async { - if (classRoom.client.userID != _event.stateKey) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "should not be in bulkUpdate ${classRoom.client.userID} != ${_event.stateKey}", - ); - return; - } - for (final message in messages) { - await removeEdittedMessages(message); - } - - _messagesToSave.addAll(messages); - _updateStudentAnalytics(); - } - - Future _updateStudentAnalytics() async { - content.lastUpdated = DateTime.now(); - content.addAll(_messagesToSave); - debugPrint("updating student analytics"); - _clearMessages(); - - await classRoom.client.setRoomStateWithKey( - classRoom.id, - _event.type, - _event.stateKey!, - content.toJson(), - ); - } - - _addMessage(RecentMessageRecord message) { - if (_messagesToSave.every((e) => e.eventId != message.eventId)) { - _messagesToSave.add(message); - } else { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "adding message twice in StudentAnalyticsEvent._addMessage", - ); - } - //PTODO - save to local storagge - } - - _clearMessages() { - _messagesToSave.clear(); - //PTODO - clear local storagge - } - - Future getTotals(String? chatId) async { - final TimeSeriesTotals totals = TimeSeriesTotals.empty; - final msgs = chatId == null - ? content.messages - : content.messages.where((msg) => msg.chatId == chatId); - for (final msg in msgs) { - totals.increment(msg); - } - return totals; - } - - Future getTimeServiesInterval( - DateTime start, - DateTime end, - String? chatId, - ) async { - final TimeSeriesInterval interval = TimeSeriesInterval( - start: start, - end: end, - totals: TimeSeriesTotals.empty, - ); - for (final msg in content.messages) { - if (msg.time.isAfter(start) && - msg.time.isBefore(end) && - (chatId == null || chatId == msg.chatId)) { - interval.totals.increment(msg); - } - } - return interval; - } -} diff --git a/lib/pangea/models/student_analytics_event_old.dart b/lib/pangea/models/student_analytics_event_old.dart deleted file mode 100644 index d2696eb01..000000000 --- a/lib/pangea/models/student_analytics_event_old.dart +++ /dev/null @@ -1,51 +0,0 @@ -// import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; -// import 'package:fluffychat/pangea/models/analytics_model_older.dart'; -// import 'package:matrix/matrix.dart'; - -// import '../constants/pangea_event_types.dart'; - -// class StudentAnalyticsEvent { -// late Event _event; -// StudentAnalyticsSummary? _contentCache; - -// StudentAnalyticsEvent({required Event event}) { -// if (event.type != PangeaEventTypes.studentAnalyticsSummary) { -// throw Exception( -// "${event.type} should not be used to make a StudentAnalyticsEvent", -// ); -// } -// _event = event; -// } - -// Event get event => _event; - -// StudentAnalyticsSummary get _content { -// _contentCache ??= event.getPangeaContent(); -// return _contentCache!; -// } - -// List get monthly => _content.monthlyTotalsForAllTime; -// List get daily => _content.dailyTotalsForLast30Days; -// List get hourly => _content.hourlyTotalsForLast24Hours; - -// // updateLocal -// // updateServer -// handleNewMessage() {} - -// /// if monthly.isNotEmpty && last.end.month < now.month -// /// push empty intervals until last.end.month >= now.month -// /// if daily.isEmpty -// /// push empty intervals until last.end.day >= now.day -// /// else if daily.where(e => e.month < now.month) -// /// sum and add to monthly -// /// -// /// if hourly.isEmpty || last.end.hour < now.hour -// /// push empty intervals until last.end.hour >= now.hour -// /// increment hourly - -// updateLocal() {} - -// // if server copy is older than x, push local version -// // get new server copy, local version = server copy -// updateServer() {} -// } diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 1738223fb..53bd72922 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -7,122 +11,148 @@ import 'package:matrix/matrix.dart'; import '../../../../utils/date_time_extension.dart'; import '../../../widgets/avatar.dart'; import '../../../widgets/matrix.dart'; -import '../../models/chart_analytics_model.dart'; +import '../../models/analytics/chart_analytics_model.dart'; import 'base_analytics.dart'; import 'list_summary_analytics.dart'; class AnalyticsListTile extends StatefulWidget { const AnalyticsListTile({ super.key, - required this.model, - required this.displayName, - required this.avatar, - required this.type, - required this.id, - required this.allowNavigateOnSelect, + required this.defaultSelected, required this.selected, + required this.avatar, + required this.allowNavigateOnSelect, + required this.isSelected, required this.onTap, - this.enabled = true, - this.showSpaceAnalytics = true, + required this.pangeaController, + this.controller, + // this.isEnabled = true, + // this.showSpaceAnalytics = true, + this.refreshStream, }); - final Uri? avatar; - final String displayName; - final AnalyticsEntryType type; - final String id; - final ChartAnalyticsModel? model; - final bool allowNavigateOnSelect; final void Function(AnalyticsSelected) onTap; - final bool selected; - final bool enabled; - final bool showSpaceAnalytics; + + final AnalyticsSelected defaultSelected; + final AnalyticsSelected selected; + + final Uri? avatar; + + final bool allowNavigateOnSelect; + final bool isSelected; + // final bool isEnabled; + // final bool showSpaceAnalytics; + + final PangeaController pangeaController; + final BaseAnalyticsController? controller; + final StreamController? refreshStream; @override AnalyticsListTileState createState() => AnalyticsListTileState(); } class AnalyticsListTileState extends State { + ChartAnalyticsModel? tileData; + StreamSubscription? refreshSubscription; + + @override + void initState() { + super.initState(); + setTileData(); + refreshSubscription = widget.refreshStream?.stream.listen((forceUpdate) { + setTileData(forceUpdate: forceUpdate); + }); + } + + @override + void dispose() { + refreshSubscription?.cancel(); + super.dispose(); + } + + Future setTileData({forceUpdate = false}) async { + tileData = await MatrixState.pangeaController.analytics.getAnalytics( + defaultSelected: widget.defaultSelected, + selected: widget.selected, + forceUpdate: forceUpdate, + ); + if (mounted) setState(() {}); + } + @override Widget build(BuildContext context) { - final Room? room = Matrix.of(context).client.getRoomById(widget.id); + final Room? room = + Matrix.of(context).client.getRoomById(widget.selected.id); return Material( - color: widget.selected + color: widget.isSelected ? Theme.of(context).colorScheme.secondaryContainer : Colors.transparent, - child: Opacity( - opacity: widget.enabled ? 1 : 0.5, - child: Tooltip( - message: widget.enabled - ? "" - : widget.type == AnalyticsEntryType.room - ? L10n.of(context)!.joinToView - : L10n.of(context)!.studentAnalyticsNotAvailable, - child: ListTile( - leading: widget.type == AnalyticsEntryType.privateChats - ? CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - radius: Avatar.defaultSize / 2, - child: const Icon(Icons.forum), - ) - : Avatar( - mxContent: widget.avatar, - name: widget.displayName, - littleIcon: room?.roomTypeIcon, - ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - widget.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.bodyLarge!.color, - ), + child: Tooltip( + message: widget.selected.type == AnalyticsEntryType.room + ? L10n.of(context)!.joinToView + : L10n.of(context)!.studentAnalyticsNotAvailable, + child: ListTile( + leading: widget.selected.type == AnalyticsEntryType.privateChats + ? CircleAvatar( + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + radius: Avatar.defaultSize / 2, + child: const Icon(Icons.forum), + ) + : Avatar( + mxContent: widget.avatar, + name: widget.selected.displayName, + littleIcon: room?.roomTypeIcon, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.selected.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.bodyLarge!.color, ), ), - Tooltip( - message: L10n.of(context)!.timeOfLastMessage, - child: Text( - widget.model?.lastMessage?.localizedTimeShort(context) ?? - "", - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium!.color, - ), + ), + Tooltip( + message: L10n.of(context)!.timeOfLastMessage, + child: Text( + tileData?.lastMessageTime?.localizedTimeShort(context) ?? "", + style: TextStyle( + fontSize: 13, + color: Theme.of(context).textTheme.bodyMedium!.color, ), ), - ], - ), - subtitle: widget.showSpaceAnalytics || !(room?.isSpace ?? false) - ? ListSummaryAnalytics( - chartAnalytics: widget.model, - ) - : null, - selected: widget.selected, - onTap: () { - (room?.isSpace ?? false) && widget.allowNavigateOnSelect - ? context.go( - '/rooms/analytics/${room!.id}', - ) - : widget.onTap( - AnalyticsSelected( - widget.id, - widget.type, - widget.displayName, - ), - ); - }, - trailing: (room?.isSpace ?? false) && - widget.type != AnalyticsEntryType.privateChats && - widget.allowNavigateOnSelect - ? const Icon(Icons.chevron_right) - : null, + ), + ], ), + subtitle: ListSummaryAnalytics( + chartAnalytics: tileData, + ), + selected: widget.isSelected, + onTap: () { + if (widget.controller?.widget.selectedView == null) { + widget.onTap(widget.selected); + return; + } + if ((room?.isSpace ?? false) && widget.allowNavigateOnSelect) { + final String selectedView = + widget.controller!.widget.selectedView!.route; + context.go('/rooms/analytics/${room!.id}/$selectedView'); + return; + } + widget.onTap(widget.selected); + }, + trailing: (room?.isSpace ?? false) && + widget.selected.type != AnalyticsEntryType.privateChats && + widget.allowNavigateOnSelect + ? const Icon(Icons.chevron_right) + : null, ), ), ); diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 17fa4013a..2b548226b 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -1,7 +1,9 @@ import 'dart:async'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/constants/pangea_event_types.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/models/analytics/analytics_event.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics_view.dart'; import 'package:fluffychat/pangea/pages/analytics/student_analytics/student_analytics.dart'; import 'package:flutter/material.dart'; @@ -12,12 +14,12 @@ import '../../../widgets/matrix.dart'; import '../../controllers/pangea_controller.dart'; import '../../enum/bar_chart_view_enum.dart'; import '../../enum/time_span.dart'; -import '../../models/chart_analytics_model.dart'; +import '../../models/analytics/chart_analytics_model.dart'; class BaseAnalyticsPage extends StatefulWidget { final String pageTitle; final List tabs; - final Future Function(BuildContext) refreshData; + final BarChartViewSelection? selectedView; final AnalyticsSelected defaultSelected; final AnalyticsSelected? alwaysSelected; @@ -27,9 +29,9 @@ class BaseAnalyticsPage extends StatefulWidget { super.key, required this.pageTitle, required this.tabs, - required this.refreshData, required this.alwaysSelected, required this.defaultSelected, + this.selectedView, this.myAnalyticsController, }); @@ -39,156 +41,126 @@ class BaseAnalyticsPage extends StatefulWidget { class BaseAnalyticsController extends State { final PangeaController pangeaController = MatrixState.pangeaController; - BarChartViewSelection? selectedView; AnalyticsSelected? selected; String? currentLemma; + ChartAnalyticsModel? chartData; + StreamController refreshStream = StreamController.broadcast(); bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id; - ChartAnalyticsModel? chartData( - BuildContext context, - AnalyticsSelected? selectedParam, - ) { - final AnalyticsSelected analyticsSelected = - selectedParam ?? widget.defaultSelected; + Room? get activeSpace { + if (widget.defaultSelected.type == AnalyticsEntryType.space) { + return Matrix.of(context).client.getRoomById(widget.defaultSelected.id); + } + return null; + } - if (analyticsSelected.type == AnalyticsEntryType.privateChats) { - return pangeaController.analytics.getAnalyticsLocal( - classId: analyticsSelected.id, - chatId: AnalyticsEntryType.privateChats.toString(), + @override + void initState() { + super.initState(); + if (widget.defaultSelected.type == AnalyticsEntryType.student) { + runFirstRefresh(); + } + setChartData(); + } + + @override + void didUpdateWidget(covariant BaseAnalyticsPage oldWidget) { + // when a user is a parent space's analytics and clicks on a subspace + super.didUpdateWidget(oldWidget); + if (oldWidget.defaultSelected.id != widget.defaultSelected.id) { + setChartData(); + refreshStream.add(false); + } + } + + Future runFirstRefresh() async { + final analyticsRooms = + pangeaController.matrixState.client.allMyAnalyticsRooms; + + final List analyticsEvent = []; + for (final analyticsRoom in analyticsRooms) { + final lastSummaryEvent = await analyticsRoom.getLastAnalyticsEvent( + PangeaEventTypes.summaryAnalytics, + Matrix.of(context).client.userID!, ); + final lastConstructEvent = await analyticsRoom.getLastAnalyticsEvent( + PangeaEventTypes.construct, + Matrix.of(context).client.userID!, + ); + if (lastSummaryEvent != null) { + analyticsEvent.add(lastSummaryEvent); + } + if (lastConstructEvent != null) { + analyticsEvent.add(lastConstructEvent); + } } - String? chatId = analyticsSelected.type == AnalyticsEntryType.room - ? analyticsSelected.id - : null; - chatId ??= widget.alwaysSelected?.type == AnalyticsEntryType.room - ? widget.alwaysSelected?.id - : null; + if (analyticsEvent.isNotEmpty) return; + onRefresh(); + } - String? studentId = analyticsSelected.type == AnalyticsEntryType.student - ? analyticsSelected.id - : null; - studentId ??= widget.alwaysSelected?.type == AnalyticsEntryType.student - ? widget.alwaysSelected?.id - : null; + Future onRefresh() async { + // postframe callback to avoid calling this function during build + WidgetsBinding.instance.addPostFrameCallback((_) async { + await showFutureLoadingDialog( + context: context, + future: () async { + debugPrint("updating analytics"); + await pangeaController.myAnalytics.updateAnalytics(); + await setChartData(forceUpdate: true); + refreshStream.add(true); + }, + ); + }); + } - String? classId = analyticsSelected.type == AnalyticsEntryType.space - ? analyticsSelected.id - : null; - classId ??= widget.alwaysSelected?.type == AnalyticsEntryType.space - ? widget.alwaysSelected?.id - : null; - - final data = pangeaController.analytics.getAnalyticsLocal( - classId: classId, - chatId: chatId, - studentId: studentId, + Future fetchChartData( + AnalyticsSelected? params, { + forceUpdate = false, + }) async { + final ChartAnalyticsModel data = + await pangeaController.analytics.getAnalytics( + defaultSelected: widget.defaultSelected, + selected: params, + forceUpdate: forceUpdate, ); return data; } + Future setChartData({forceUpdate = false}) async { + final ChartAnalyticsModel newData = await fetchChartData( + selected, + forceUpdate: forceUpdate, + ); + setState(() => chartData = newData); + } + TimeSpan get currentTimeSpan => pangeaController.analytics.currentAnalyticsTimeSpan; - void navigate() { - if (currentLemma != null) { - setCurrentLemma(null); - } else if (selectedView != null) { - setSelectedView(null); - } else { - Navigator.of(context).pop(); - } - } - Future toggleSelection(AnalyticsSelected selectedParam) async { - final bool joinSelectedRoom = - selectedParam.type == AnalyticsEntryType.room && - !enableSelection( - selectedParam, - ); - - if (joinSelectedRoom) { - await showFutureLoadingDialog( - context: context, - future: () async { - final waitForRoom = Matrix.of(context).client.waitForRoomInSync( - selectedParam.id, - join: true, - ); - await Matrix.of(context).client.joinRoom(selectedParam.id); - await waitForRoom; - }, - ); - } - setState(() { debugPrint("selectedParam.id is ${selectedParam.id}"); currentLemma = null; selected = isSelected(selectedParam.id) ? null : selectedParam; }); - - pangeaController.analytics.setConstructs( - constructType: ConstructType.grammar, - defaultSelected: widget.defaultSelected, - selected: selected, - removeIT: true, - ); - + await setChartData(); + refreshStream.add(false); Future.delayed(Duration.zero, () => setState(() {})); } Future toggleTimeSpan(BuildContext context, TimeSpan timeSpan) async { await pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan); - await widget.refreshData(context); - await pangeaController.analytics.setConstructs( - constructType: ConstructType.grammar, - defaultSelected: widget.defaultSelected, - selected: selected, - removeIT: true, - ); - setState(() {}); - } - - void setSelectedView(BarChartViewSelection? view) { - currentLemma = null; - selectedView = view; - if (!enableSelection(selected)) { - toggleSelection(selected!); - } - setState(() {}); + await setChartData(); + refreshStream.add(false); } void setCurrentLemma(String? lemma) { currentLemma = lemma; setState(() {}); - } - - bool enableSelection(AnalyticsSelected? selectedParam) { - if (selectedView == BarChartViewSelection.grammar) { - if (selectedParam?.type == AnalyticsEntryType.room) { - return Matrix.of(context) - .client - .getRoomById(selectedParam!.id) - ?.membership == - Membership.join; - } - - if (selectedParam?.type == AnalyticsEntryType.student) { - final String? langCode = - pangeaController.languageController.activeL2Code( - roomID: widget.defaultSelected.id, - ); - if (langCode == null) return false; - return Matrix.of(context).client.analyticsRoomLocal( - langCode, - selectedParam?.id, - ) != - null; - } - } - return true; + refreshStream.add(false); } @override @@ -221,6 +193,19 @@ class TabItem { enum AnalyticsEntryType { student, room, space, privateChats } +extension AnalyticsEntryTypeExtension on AnalyticsEntryType { + String get route { + switch (this) { + case AnalyticsEntryType.student: + return 'mylearning'; + case AnalyticsEntryType.space: + return 'analytics'; + default: + throw Exception('No route for $this'); + } + } +} + class AnalyticsSelected { String id; AnalyticsEntryType type; diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 0c9bf3bc4..09805ef71 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; class BaseAnalyticsView extends StatelessWidget { const BaseAnalyticsView({ @@ -22,17 +23,14 @@ class BaseAnalyticsView extends StatelessWidget { final BaseAnalyticsController controller; Widget chartView(BuildContext context) { - if (controller.selectedView == null) { + if (controller.widget.selectedView == null) { return const SizedBox(); } - switch (controller.selectedView!) { + switch (controller.widget.selectedView!) { case BarChartViewSelection.messages: return MessagesBarChart( - chartAnalytics: controller.chartData( - context, - controller.selected, - ), + chartAnalytics: controller.chartData, ); case BarChartViewSelection.grammar: return ConstructList( @@ -41,6 +39,7 @@ class BaseAnalyticsView extends StatelessWidget { selected: controller.selected, controller: controller, pangeaController: controller.pangeaController, + refreshStream: controller.refreshStream, ); } } @@ -62,55 +61,68 @@ class BaseAnalyticsView extends StatelessWidget { text: controller.widget.pageTitle, style: const TextStyle(decoration: TextDecoration.underline), recognizer: TapGestureRecognizer() - ..onTap = () => controller.selectedView != null - ? controller.setSelectedView(null) - : null, + ..onTap = () { + final String route = + "/rooms/${controller.widget.defaultSelected.type.route}"; + context.go(route); + }, ), - if (controller.selectedView != null) + if (controller.activeSpace != null) const TextSpan( text: " > ", ), - if (controller.selectedView != null) + if (controller.activeSpace != null) TextSpan( + text: controller.activeSpace!.getLocalizedDisplayname(), style: const TextStyle(decoration: TextDecoration.underline), - text: controller.selectedView!.string(context), recognizer: TapGestureRecognizer() - ..onTap = () => controller.currentLemma != null - ? controller.setCurrentLemma(null) - : null, + ..onTap = () { + if (controller.widget.selectedView == null) return; + String route = + "/rooms/${controller.widget.defaultSelected.type.route}"; + if (controller.widget.defaultSelected.type == + AnalyticsEntryType.space) { + route += "/${controller.widget.defaultSelected.id}"; + } + context.go(route); + }, ), - if (controller.currentLemma != null) + if (controller.widget.selectedView != null) const TextSpan( text: " > ", ), - if (controller.currentLemma != null) + if (controller.widget.selectedView != null) TextSpan( - text: controller.currentLemma, - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = () {}, + text: controller.widget.selectedView!.string(context), ), ], ), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: controller.navigate, - ), - actions: [ - TimeSpanMenuButton( - value: controller.currentTimeSpan, - onChange: (TimeSpan value) => - controller.toggleTimeSpan(context, value), - ), - ], ), body: MaxWidthBody( withScrolling: false, - child: controller.selectedView != null + child: controller.widget.selectedView != null ? Column( children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (controller.widget.defaultSelected.type == + AnalyticsEntryType.student) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: controller.onRefresh, + tooltip: L10n.of(context)!.refresh, + ), + TimeSpanMenuButton( + value: controller.currentTimeSpan, + onChange: (TimeSpan value) => + controller.toggleTimeSpan(context, value), + ), + ], + ), Expanded( flex: 1, child: chartView(context), @@ -153,29 +165,18 @@ class BaseAnalyticsView extends StatelessWidget { children: [ ...controller.widget.tabs[0].items.map( (item) => AnalyticsListTile( + refreshStream: + controller.refreshStream, avatar: item.avatar, - model: controller.chartData( - context, - AnalyticsSelected( - item.id, - controller.widget.tabs[0].type, - "", - ), + defaultSelected: controller + .widget.defaultSelected, + selected: AnalyticsSelected( + item.id, + controller.widget.tabs[0].type, + item.displayName, ), - displayName: item.displayName, - id: item.id, - type: - controller.widget.tabs[0].type, - selected: + isSelected: controller.isSelected(item.id), - enabled: controller.enableSelection( - AnalyticsSelected( - item.id, - controller.widget.tabs[0].type, - "", - ), - ), - showSpaceAnalytics: false, onTap: (_) => controller.toggleSelection( AnalyticsSelected( @@ -188,35 +189,35 @@ class BaseAnalyticsView extends StatelessWidget { .widget .tabs[0] .allowNavigateOnSelect, + pangeaController: + controller.pangeaController, + controller: controller, ), ), if (controller .widget.defaultSelected.type == AnalyticsEntryType.space) AnalyticsListTile( + refreshStream: + controller.refreshStream, + defaultSelected: controller + .widget.defaultSelected, avatar: null, - model: controller.chartData( - context, - AnalyticsSelected( - controller - .widget.defaultSelected.id, - AnalyticsEntryType.privateChats, - L10n.of(context)! - .allPrivateChats, - ), + selected: AnalyticsSelected( + controller + .widget.defaultSelected.id, + AnalyticsEntryType.privateChats, + L10n.of(context)!.allPrivateChats, ), - displayName: L10n.of(context)! - .allPrivateChats, - id: controller - .widget.defaultSelected.id, - type: - AnalyticsEntryType.privateChats, allowNavigateOnSelect: false, - selected: controller.isSelected( + isSelected: controller.isSelected( controller .widget.defaultSelected.id, ), onTap: controller.toggleSelection, + pangeaController: + controller.pangeaController, + controller: controller, ), ], ), @@ -226,36 +227,26 @@ class BaseAnalyticsView extends StatelessWidget { children: controller.widget.tabs[1].items .map( (item) => AnalyticsListTile( + refreshStream: + controller.refreshStream, avatar: item.avatar, - model: controller.chartData( - context, - AnalyticsSelected( - item.id, - controller - .widget.tabs[1].type, - "", - ), + defaultSelected: controller + .widget.defaultSelected, + selected: AnalyticsSelected( + item.id, + controller.widget.tabs[1].type, + item.displayName, ), - displayName: item.displayName, - id: item.id, - type: controller - .widget.tabs[1].type, - selected: controller + isSelected: controller .isSelected(item.id), onTap: controller.toggleSelection, allowNavigateOnSelect: controller .widget .tabs[1] .allowNavigateOnSelect, - enabled: - controller.enableSelection( - AnalyticsSelected( - item.id, - controller - .widget.tabs[1].type, - "", - ), - ), + pangeaController: + controller.pangeaController, + controller: controller, ), ) .toList(), @@ -275,7 +266,7 @@ class BaseAnalyticsView extends StatelessWidget { children: [ const Divider(height: 1), ListTile( - title: const Text("Error Analytics"), + title: Text(L10n.of(context)!.grammarAnalytics), leading: CircleAvatar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -284,13 +275,20 @@ class BaseAnalyticsView extends StatelessWidget { child: Icon(BarChartViewSelection.grammar.icon), ), trailing: const Icon(Icons.chevron_right), - onTap: () => controller.setSelectedView( - BarChartViewSelection.grammar, - ), + onTap: () { + String route = + "/rooms/${controller.widget.defaultSelected.type.route}"; + if (controller.widget.defaultSelected.type == + AnalyticsEntryType.space) { + route += "/${controller.widget.defaultSelected.id}"; + } + route += "/${BarChartViewSelection.grammar.route}"; + context.go(route); + }, ), const Divider(height: 1), ListTile( - title: const Text("Message Analytics"), + title: Text(L10n.of(context)!.messageAnalytics), leading: CircleAvatar( backgroundColor: Theme.of(context).scaffoldBackgroundColor, @@ -299,9 +297,16 @@ class BaseAnalyticsView extends StatelessWidget { child: Icon(BarChartViewSelection.messages.icon), ), trailing: const Icon(Icons.chevron_right), - onTap: () => controller.setSelectedView( - BarChartViewSelection.messages, - ), + onTap: () { + String route = + "/rooms/${controller.widget.defaultSelected.type.route}"; + if (controller.widget.defaultSelected.type == + AnalyticsEntryType.space) { + route += "/${controller.widget.defaultSelected.id}"; + } + route += "/${BarChartViewSelection.messages.route}"; + context.go(route); + }, ), const Divider(height: 1), ], diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 4de8d4184..9bf5ac7a3 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -1,12 +1,9 @@ import 'dart:async'; import 'dart:developer'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/models/chart_analytics_model.dart'; -import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart'; import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart'; @@ -16,92 +13,71 @@ import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; import '../../../../widgets/matrix.dart'; -import '../../../controllers/pangea_controller.dart'; import '../../../utils/sync_status_util_v2.dart'; import 'class_analytics_view.dart'; enum AnalyticsPageType { classList, student, classDetails } class ClassAnalyticsPage extends StatefulWidget { - // final AnalyticsPageType type; - const ClassAnalyticsPage({super.key}); + final BarChartViewSelection? selectedView; + const ClassAnalyticsPage({super.key, this.selectedView}); @override State createState() => ClassAnalyticsV2Controller(); } class ClassAnalyticsV2Controller extends State { - final PangeaController _pangeaController = MatrixState.pangeaController; bool _initialized = false; - StreamSubscription? stateSub; - Timer? refreshTimer; + // StreamSubscription? stateSub; + // Timer? refreshTimer; List chats = []; List students = []; - String? get classId => GoRouterState.of(context).pathParameters['classid']; - Room? _classRoom; + Room? get classRoom { if (_classRoom == null || _classRoom!.id != classId) { debugPrint("updating _classRoom"); _classRoom = classId != null ? Matrix.of(context).client.getRoomById(classId!) : null; - - getChatAndStudents() - .then( - (_) => _pangeaController.analytics.setConstructs( - constructType: ConstructType.grammar, - defaultSelected: AnalyticsSelected( - classId!, - AnalyticsEntryType.space, - className(context), - ), - removeIT: true, - forceUpdate: true, - ), - ) - .then( - (_) => getChatAndStudentAnalytics(context, true), - ); + if (_classRoom == null) { + context.go('/rooms/analytics'); + } + getChatAndStudents(); } return _classRoom; } - String className(BuildContext context) { - return classRoom?.name ?? ""; - } - @override void initState() { super.initState(); + debugPrint("init class analytics"); Future.delayed(Duration.zero, () async { if (classRoom == null || (!(classRoom?.isSpace ?? false))) { context.go('/rooms'); } - - stateSub = _pangeaController.matrixState.client.onRoomState.stream - .where( - (event) => - event.type == PangeaEventTypes.studentAnalyticsSummary && - event.roomId == classId, - ) - .listen(onStateUpdate); getChatAndStudents(); }); } Future getChatAndStudents() async { try { + await classRoom?.postLoad(); await classRoom?.requestParticipants(); if (classRoom != null) { final response = await Matrix.of(context).client.getSpaceHierarchy( classRoom!.id, - maxDepth: 1, ); + // set the latest fetched full hierarchy in message analytics controller + // we want to avoid calling this endpoint again and again, so whenever the + // data is made available, set it in the controller + MatrixState.pangeaController.analytics + .setLatestHierarchy(_classRoom!.id, response); + students = classRoom!.students; chats = response.rooms .where( @@ -122,21 +98,12 @@ class ClassAnalyticsV2Controller extends State { } } - void onStateUpdate(Event newState) { - if (!(refreshTimer?.isActive ?? false)) { - refreshTimer = Timer( - const Duration(seconds: 3), - () => getChatAndStudentAnalytics(context, true), - ); - } - } - - @override - void dispose() { - super.dispose(); - refreshTimer?.cancel(); - stateSub?.cancel(); - } + // @override + // void dispose() { + // super.dispose(); + // refreshTimer?.cancel(); + // stateSub?.cancel(); + // } @override Widget build(BuildContext context) { @@ -146,57 +113,10 @@ class ClassAnalyticsV2Controller extends State { // but this is computationally expensive! // key: UniqueKey(), shimmerChild: const ListPlaceholder(), - onFinish: () { - getChatAndStudentAnalytics(context); - }, + // onFinish: () { + // getChatAndStudentAnalytics(context); + // }, child: ClassAnalyticsView(this), ); } - - Future getChatAndStudentAnalytics( - BuildContext context, [ - forceUpdate = false, - ]) async { - try { - if (classRoom == null) { - debugger(when: kDebugMode); - ErrorHandler.logError(m: 'classroom should not be null'); - } - final List> analyticsFutures = []; - for (final student in students) { - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - classRoom: classRoom, - studentId: student.id, - forceUpdate: forceUpdate, - ), - ); - } - for (final chat in chats) { - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - classRoom: classRoom, - chatId: chat.roomId, - forceUpdate: forceUpdate, - ), - ); - } - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - classRoom: classRoom, - forceUpdate: forceUpdate, - ), - ); - analyticsFutures.add( - _pangeaController.analytics.getAnalyticsForPrivateChats( - classRoom: classRoom, - forceUpdate: forceUpdate, - ), - ); - await Future.wait(analyticsFutures); - if (mounted) setState(() {}); - } catch (err) { - debugger(when: kDebugMode); - } - } } diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart index 88c15bdf5..5f609437b 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart @@ -48,18 +48,18 @@ class ClassAnalyticsView extends StatelessWidget { return controller.classId != null ? BaseAnalyticsPage( + selectedView: controller.widget.selectedView, pageTitle: pageTitle, tabs: [tab1, tab2], - refreshData: controller.getChatAndStudentAnalytics, alwaysSelected: AnalyticsSelected( controller.classId!, AnalyticsEntryType.space, - controller.className(context), + controller.classRoom?.name ?? "", ), defaultSelected: AnalyticsSelected( controller.classId!, AnalyticsEntryType.space, - controller.className(context), + controller.classRoom?.name ?? "", ), ) : const SizedBox(); diff --git a/lib/pangea/pages/analytics/class_list/class_list.dart b/lib/pangea/pages/analytics/class_list/class_list.dart index e96538a11..45aa6bb88 100644 --- a/lib/pangea/pages/analytics/class_list/class_list.dart +++ b/lib/pangea/pages/analytics/class_list/class_list.dart @@ -1,14 +1,15 @@ import 'dart:async'; import 'package:fluffychat/pangea/enum/time_span.dart'; +import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; +import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; import 'package:fluffychat/pangea/pages/analytics/class_list/class_list_view.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import '../../../../widgets/matrix.dart'; -import '../../../constants/pangea_event_types.dart'; import '../../../controllers/pangea_controller.dart'; -import '../../../models/chart_analytics_model.dart'; +import '../../../models/analytics/chart_analytics_model.dart'; import '../../../utils/sync_status_util_v2.dart'; import '../../../widgets/common/list_placeholder.dart'; @@ -22,75 +23,57 @@ class AnalyticsClassList extends StatefulWidget { class AnalyticsClassListController extends State { PangeaController pangeaController = MatrixState.pangeaController; List models = []; - StreamSubscription? stateSub; - Map refreshTimer = {}; + List spaces = []; @override void initState() { super.initState(); - Future.delayed(Duration.zero, () async { - stateSub = pangeaController.matrixState.client.onRoomState.stream + Matrix.of(context).client.classesAndExchangesImTeaching.then((spaceList) { + spaceList = spaceList .where( - (event) => event.type == PangeaEventTypes.studentAnalyticsSummary, + (space) => !spaceList.any( + (parentSpace) => parentSpace.spaceChildren + .any((child) => child.roomId == space.id), + ), ) - .listen(onStateUpdate); + .toList(); + spaces = spaceList; + setState(() {}); }); } - void onStateUpdate(Event newState) { - if (!(refreshTimer[newState.room.id]?.isActive ?? false)) { - refreshTimer[newState.room.id] = Timer( - const Duration(seconds: 3), - () { - if (newState.room.isSpace) { - updateClassAnalytics(context, newState.room); - } - }, - ); - } - } - - @override - void dispose() { - super.dispose(); - for (final timer in refreshTimer.values) { - timer.cancel(); - } - stateSub?.cancel(); - } - @override Widget build(BuildContext context) { return PLoadingStatusV2( shimmerChild: const ListPlaceholder(), child: AnalyticsClassListView(this), onFinish: () { - getAllClassAnalytics(context); + // getAllClassAnalytics(context); }, ); } - Future getAllClassAnalytics(BuildContext context) async { - await pangeaController.analytics.allClassAnalytics(); - setState(() { - debugPrint("class list post getAllClassAnalytics"); - }); - } - - Future updateClassAnalytics( - BuildContext context, - Room classRoom, + Future updateClassAnalytics( + Room? space, ) async { - await pangeaController.analytics - .getAnalytics(classRoom: classRoom, forceUpdate: true); - setState(() { - debugPrint("class list post updateClassAnalytics"); - }); + if (space == null) { + return null; + } + + final data = await pangeaController.analytics.getAnalytics( + defaultSelected: AnalyticsSelected( + space.id, + AnalyticsEntryType.space, + space.name, + ), + forceUpdate: true, + ); + setState(() {}); + return data; } void toggleTimeSpan(BuildContext context, TimeSpan timeSpan) { pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan); setState(() {}); - getAllClassAnalytics(context); } } diff --git a/lib/pangea/pages/analytics/class_list/class_list_view.dart b/lib/pangea/pages/analytics/class_list/class_list_view.dart index 88bea1e01..fe63d7675 100644 --- a/lib/pangea/pages/analytics/class_list/class_list_view.dart +++ b/lib/pangea/pages/analytics/class_list/class_list_view.dart @@ -1,11 +1,9 @@ -import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart'; import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import '../../../../widgets/matrix.dart'; import '../../../enum/time_span.dart'; import '../base_analytics.dart'; import 'class_list.dart'; @@ -45,24 +43,28 @@ class AnalyticsClassListView extends StatelessWidget { body: Column( children: [ Flexible( - child: FutureBuilder( - future: Matrix.of(context).client.classesAndExchangesImTeaching, - builder: (context, snapshot) => ListView.builder( - itemCount: snapshot.hasData ? snapshot.data?.length ?? 0 : 0, - itemBuilder: (context, i) => AnalyticsListTile( - avatar: snapshot.data![i].avatar, - model: controller.pangeaController.analytics - .getAnalyticsLocal(classId: snapshot.data![i].id), - displayName: snapshot.data![i].name, - id: snapshot.data![i].id, - type: AnalyticsEntryType.space, - // selected: false, - onTap: (selected) => context.go( - '/rooms/analytics/${selected.id}', - ), - allowNavigateOnSelect: true, - selected: false, + child: ListView.builder( + itemCount: controller.spaces.length, + itemBuilder: (context, i) => AnalyticsListTile( + defaultSelected: AnalyticsSelected( + controller.spaces[i].id, + AnalyticsEntryType.space, + "", ), + avatar: controller.spaces[i].avatar, + selected: AnalyticsSelected( + controller.spaces[i].id, + AnalyticsEntryType.space, + controller.spaces[i].name, + ), + onTap: (selected) { + context.go( + '/rooms/analytics/${selected.id}', + ); + }, + allowNavigateOnSelect: true, + isSelected: false, + pangeaController: controller.pangeaController, ), ), ), diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index f050222ec..e169922ca 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -2,13 +2,12 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.dart'; -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/construct_type_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart'; -import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -25,6 +24,7 @@ class ConstructList extends StatefulWidget { final AnalyticsSelected? selected; final BaseAnalyticsController controller; final PangeaController pangeaController; + final StreamController refreshStream; const ConstructList({ super.key, @@ -32,6 +32,7 @@ class ConstructList extends StatefulWidget { required this.defaultSelected, required this.controller, required this.pangeaController, + required this.refreshStream, this.selected, }); @@ -40,29 +41,9 @@ class ConstructList extends StatefulWidget { } class ConstructListState extends State { - bool initialized = false; String? langCode; String? error; - @override - void initState() { - super.initState(); - widget.pangeaController.analytics - .setConstructs( - constructType: widget.constructType, - removeIT: true, - defaultSelected: widget.defaultSelected, - selected: widget.selected, - forceUpdate: true, - ) - .whenComplete(() => setState(() => initialized = true)); - } - - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { return error != null @@ -72,11 +53,11 @@ class ConstructListState extends State { : Column( children: [ ConstructListView( - init: initialized, controller: widget.controller, pangeaController: widget.pangeaController, defaultSelected: widget.defaultSelected, selected: widget.selected, + refreshStream: widget.refreshStream, ), ], ); @@ -93,19 +74,18 @@ class ConstructListState extends State { // subtitle = total uses, equal to construct.content.uses.length // list has a fixed height of 400 and is scrollable class ConstructListView extends StatefulWidget { - // final List constructs; - final bool init; final BaseAnalyticsController controller; final PangeaController pangeaController; final AnalyticsSelected defaultSelected; final AnalyticsSelected? selected; + final StreamController refreshStream; const ConstructListView({ super.key, - required this.init, required this.controller, required this.pangeaController, required this.defaultSelected, + required this.refreshStream, this.selected, }); @@ -114,59 +94,55 @@ class ConstructListView extends StatefulWidget { } class ConstructListViewState extends State { + final ConstructType constructType = ConstructType.grammar; final Map _timelinesCache = {}; final Map _msgEventCache = {}; final List _msgEvents = []; + bool fetchingConstructs = true; bool fetchingUses = false; - - StreamSubscription? stateSub; - Timer? refreshTimer; + StreamSubscription? refreshSubscription; @override void initState() { super.initState(); - - stateSub = Matrix.of(context) - .client - .onRoomState - .stream - //could optimize here be determing if the vocab event is relevant for - //currently displayed data - .where((event) => event.type == PangeaEventTypes.vocab) - .listen(onStateUpdate); - } - - Future onStateUpdate(Event? newState) async { - debugPrint("onStateUpdate construct list"); - if (refreshTimer?.isActive ?? false) return; - refreshTimer = Timer( - const Duration(seconds: 3), - () async { - await widget.pangeaController.analytics.setConstructs( - constructType: ConstructType.grammar, + widget.pangeaController.analytics + .getConstructs( + constructType: constructType, removeIT: true, defaultSelected: widget.defaultSelected, selected: widget.selected, forceUpdate: true, - ); - await fetchUses(); - }, - ); + ) + .whenComplete(() => setState(() => fetchingConstructs = false)) + .then((value) => setState(() => _constructs = value)); + + refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { + // postframe callback to let widget rebuild with the new selected parameter + // before sending selected to getConstructs function + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.pangeaController.analytics + .getConstructs( + constructType: constructType, + removeIT: true, + defaultSelected: widget.defaultSelected, + selected: widget.selected, + forceUpdate: true, + ) + .then( + (value) => setState(() { + _constructs = value; + }), + ); + }); + }); } @override void dispose() { + refreshSubscription?.cancel(); super.dispose(); - refreshTimer?.cancel(); - stateSub?.cancel(); } - // @override - // void didUpdateWidget(ConstructListView oldWidget) { - // super.didUpdateWidget(oldWidget); - // fetchUses(); - // } - int get lemmaIndex => constructs?.indexWhere( (element) => element.lemma == widget.controller.currentLemma, @@ -241,18 +217,47 @@ class ConstructListViewState extends State { } } - List? get constructs => - widget.pangeaController.analytics.constructs != null - ? widget.pangeaController.myAnalytics - .aggregateConstructData( - widget.pangeaController.analytics.constructs!, - ) - .sorted( - (a, b) => b.uses.length.compareTo(a.uses.length), - ) - : null; + List? _constructs; - AggregateConstructUses? get currentConstruct => constructs?.firstWhereOrNull( + List? get constructs { + if (_constructs == null) { + return null; + } + + final List filtered = List.from(_constructs!) + .map((event) => event.content.uses) + .expand((uses) => uses) + .cast() + .where((use) => use.constructType == constructType) + .toList(); + + final Map> lemmaToUses = {}; + for (final use in filtered) { + if (use.lemma == null) continue; + lemmaToUses[use.lemma!] ??= []; + lemmaToUses[use.lemma!]!.add(use); + } + + final constructUses = lemmaToUses.entries + .map( + (entry) => ConstructUses( + lemma: entry.key, + uses: entry.value, + constructType: constructType, + ), + ) + .toList(); + + constructUses.sort((a, b) { + final comp = b.uses.length.compareTo(a.uses.length); + if (comp != 0) return comp; + return a.lemma.compareTo(b.lemma); + }); + + return constructUses; + } + + ConstructUses? get currentConstruct => constructs?.firstWhereOrNull( (element) => element.lemma == widget.controller.currentLemma, ); @@ -297,13 +302,13 @@ class ConstructListViewState extends State { @override Widget build(BuildContext context) { - if (!widget.init || fetchingUses) { + if (fetchingConstructs || fetchingUses) { return const Expanded( child: Center(child: CircularProgressIndicator()), ); } - if ((constructs?.isEmpty ?? true) || - (widget.controller.currentLemma != null && currentConstruct == null)) { + + if (constructs?.isEmpty ?? true) { return Expanded( child: Center(child: Text(L10n.of(context)!.noDataFound)), ); @@ -341,7 +346,10 @@ class ConstructMessagesDialog extends StatelessWidget { @override Widget build(BuildContext context) { - if (controller.widget.controller.currentLemma == null) { + if (controller.widget.controller.currentLemma == null || + controller.constructs == null || + controller.lemmaIndex < 0 || + controller.lemmaIndex >= controller.constructs!.length) { return const AlertDialog(content: CircularProgressIndicator.adaptive()); } @@ -349,38 +357,40 @@ class ConstructMessagesDialog extends StatelessWidget { return AlertDialog( title: Center(child: Text(controller.widget.controller.currentLemma!)), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (controller.constructs![controller.lemmaIndex].uses.length > - controller._msgEvents.length) - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text(L10n.of(context)!.roomDataMissing), + content: SizedBox( + height: 350, + width: 500, + child: Column( + children: [ + if (controller.constructs![controller.lemmaIndex].uses.length > + controller._msgEvents.length) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context)!.roomDataMissing), + ), + ), + Expanded( + child: ListView( + children: [ + ...msgEventMatches.mapIndexed( + (index, event) => Column( + children: [ + ConstructMessage( + msgEvent: event.msgEvent, + lemma: controller.widget.controller.currentLemma!, + errorMessage: event.lemmaMatch, + ), + if (index < msgEventMatches.length - 1) + const Divider(height: 1), + ], + ), + ), + ], ), ), - SingleChildScrollView( - child: Column( - children: [ - ...msgEventMatches.mapIndexed( - (index, event) => Column( - children: [ - ConstructMessage( - msgEvent: event.msgEvent, - lemma: controller.widget.controller.currentLemma!, - errorMessage: event.lemmaMatch, - ), - if (index < msgEventMatches.length - 1) - const Divider(height: 1), - ], - ), - ), - ], - ), - ), - ], + ], + ), ), actions: [ TextButton( @@ -474,21 +484,21 @@ class ConstructMessage extends StatelessWidget { class ConstructMessageBubble extends StatelessWidget { final String errorText; final String replacementText; - final int? start; - final int? end; + final int start; + final int end; const ConstructMessageBubble({ super.key, required this.errorText, required this.replacementText, - this.start, - this.end, + required this.start, + required this.end, }); @override Widget build(BuildContext context) { final defaultStyle = TextStyle( - color: Theme.of(context).colorScheme.onBackground, + color: Theme.of(context).colorScheme.onSurface, fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, height: 1.3, ); @@ -516,7 +526,7 @@ class ConstructMessageBubble extends StatelessWidget { vertical: 8, ), child: RichText( - text: (start == null || end == null) + text: (end == null) ? TextSpan( text: errorText, style: defaultStyle, @@ -528,7 +538,7 @@ class ConstructMessageBubble extends StatelessWidget { style: defaultStyle, ), TextSpan( - text: errorText.substring(start!, end), + text: errorText.substring(start, end), style: defaultStyle.merge( TextStyle( backgroundColor: Colors.red.withOpacity(0.25), @@ -547,7 +557,7 @@ class ConstructMessageBubble extends StatelessWidget { ), ), TextSpan( - text: errorText.substring(end!), + text: errorText.substring(end), style: defaultStyle, ), ], @@ -569,14 +579,7 @@ class ConstructMessageMetadata extends StatelessWidget { @override Widget build(BuildContext context) { - final String roomName = msgEvent.event.room.name.isEmpty - ? Matrix.of(context) - .client - .getRoomById(msgEvent.event.room.id) - ?.getLocalizedDisplayname() ?? - "" - : msgEvent.event.room.name; - + final String roomName = msgEvent.event.room.getLocalizedDisplayname(); return Padding( padding: const EdgeInsets.fromLTRB(10, 0, 30, 0), child: Column( diff --git a/lib/pangea/pages/analytics/list_summary_analytics.dart b/lib/pangea/pages/analytics/list_summary_analytics.dart index 5b2dde5de..bf388cea7 100644 --- a/lib/pangea/pages/analytics/list_summary_analytics.dart +++ b/lib/pangea/pages/analytics/list_summary_analytics.dart @@ -1,10 +1,9 @@ import 'dart:math'; +import 'package:fluffychat/pangea/models/analytics/chart_analytics_model.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/pangea/models/chart_analytics_model.dart'; import '../../enum/use_type.dart'; class ListSummaryAnalytics extends StatelessWidget { diff --git a/lib/pangea/pages/analytics/messages_bar_chart.dart b/lib/pangea/pages/analytics/messages_bar_chart.dart index d90e44fe4..509270edb 100644 --- a/lib/pangea/pages/analytics/messages_bar_chart.dart +++ b/lib/pangea/pages/analytics/messages_bar_chart.dart @@ -10,7 +10,7 @@ import 'package:intl/intl.dart'; import '../../enum/time_span.dart'; import '../../enum/use_type.dart'; -import '../../models/chart_analytics_model.dart'; +import '../../models/analytics/chart_analytics_model.dart'; import 'bar_chart_card.dart'; import 'messages_legend_widget.dart'; @@ -58,10 +58,10 @@ class MessagesBarChartState extends State { getTitlesWidget: leftTitles, ), ), - topTitles: AxisTitles( + topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), - rightTitles: AxisTitles( + rightTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), ); diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index 1970fa236..65bd533e8 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -1,9 +1,7 @@ import 'dart:async'; import 'dart:developer'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/models/chart_analytics_model.dart'; +import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart'; import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,13 +9,13 @@ import 'package:matrix/matrix.dart'; import '../../../../widgets/matrix.dart'; import '../../../controllers/pangea_controller.dart'; -import '../../../extensions/client_extension/client_extension.dart'; import '../../../utils/sync_status_util_v2.dart'; import '../base_analytics.dart'; import 'student_analytics_view.dart'; class StudentAnalyticsPage extends StatefulWidget { - const StudentAnalyticsPage({super.key}); + final BarChartViewSelection? selectedView; + const StudentAnalyticsPage({super.key, this.selectedView}); @override State createState() => StudentAnalyticsController(); @@ -26,37 +24,55 @@ class StudentAnalyticsPage extends StatefulWidget { class StudentAnalyticsController extends State { final PangeaController _pangeaController = MatrixState.pangeaController; AnalyticsSelected? selected; - StreamSubscription? stateSub; - Timer? refreshTimer; + StreamSubscription? stateSub; - List _chats = []; - List _spaces = []; + @override + void initState() { + super.initState(); - void onStateUpdate(Event newState) { - if (!(refreshTimer?.isActive ?? false)) { - refreshTimer = Timer( - const Duration(seconds: 3), - () => getClassAndChatAnalytics(context, true), - ); - } + final listFutures = [ + _pangeaController.myAnalytics.setStudentChats(), + _pangeaController.myAnalytics.setStudentSpaces(), + ]; + Future.wait(listFutures).then((_) => setState(() {})); + + stateSub = _pangeaController.myAnalytics.stateStream.listen((_) { + setState(() {}); + }); } @override void dispose() { - super.dispose(); - refreshTimer?.cancel(); stateSub?.cancel(); + super.dispose(); } - Future initialize() async { - await getClassAndChatAnalytics(context); - stateSub = _pangeaController.matrixState.client.onRoomState.stream - .where( - (event) => - event.type == PangeaEventTypes.studentAnalyticsSummary && - event.senderId == userId, - ) - .listen(onStateUpdate); + List get chats { + if (_pangeaController.myAnalytics.studentChats.isEmpty) { + _pangeaController.myAnalytics.setStudentChats().then((_) { + if (_pangeaController.myAnalytics.studentChats.isNotEmpty) { + setState(() {}); + } + }); + } + return _pangeaController.myAnalytics.studentChats; + } + + List get spaces { + if (_pangeaController.myAnalytics.studentSpaces.isEmpty) { + _pangeaController.myAnalytics.setStudentSpaces().then((_) { + if (_pangeaController.myAnalytics.studentSpaces.isNotEmpty) { + setState(() {}); + } + }); + } + return _pangeaController.myAnalytics.studentSpaces; + } + + String? get userId { + final id = _pangeaController.matrixState.client.userID; + debugger(when: kDebugMode && id == null); + return id; } @override @@ -66,96 +82,8 @@ class StudentAnalyticsController extends State { // but this is computationally expensive! // key: UniqueKey(), shimmerChild: const ListPlaceholder(), - onFinish: initialize, + // onFinish: initialize, child: StudentAnalyticsView(this), ); } - - Future getClassAndChatAnalytics( - BuildContext context, [ - forceUpdate = false, - ]) async { - final List> analyticsFutures = []; - for (final chat in (await getChats())) { - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - chatId: chat.id, - studentId: userId, - forceUpdate: forceUpdate, - ), - ); - } - for (final space in (await getSpaces())) { - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - classRoom: space, - studentId: userId, - forceUpdate: forceUpdate, - ), - ); - } - analyticsFutures.add( - _pangeaController.analytics.getAnalytics( - studentId: userId, - forceUpdate: forceUpdate, - ), - ); - await Future.wait(analyticsFutures); - setState(() {}); - } - - Future> getSpaces() async { - final List rooms = await _pangeaController - .matrixState.client.classesAndExchangesImStudyingIn; - setState(() => _spaces = rooms); - return rooms; - } - - List? get spaces { - try { - if (_spaces.isNotEmpty) return _spaces; - getSpaces(); - return _spaces; - } catch (err) { - debugger(when: kDebugMode); - return []; - } - } - - Future> getChats() async { - final List teacherRoomIds = - await Matrix.of(context).client.teacherRoomIds; - _chats = Matrix.of(context) - .client - .rooms - .where( - (r) => - !r.isSpace && - !r.isAnalyticsRoom && - !teacherRoomIds.contains(r.id), - ) - .toList(); - setState(() => _chats = _chats); - return _chats; - } - - List? get chats { - try { - if (_chats.isNotEmpty) return _chats; - getChats(); - return _chats; - } catch (err) { - debugger(when: kDebugMode); - return []; - } - } - - String? get userId { - final id = _pangeaController.matrixState.client.userID; - debugger(when: kDebugMode && id == null); - return id; - } - - String get username => - _pangeaController.matrixState.client.userID?.localpart ?? ""; } diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart index 8d88cc685..5b8924581 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart @@ -15,7 +15,7 @@ class StudentAnalyticsView extends StatelessWidget { final TabData chatTabData = TabData( type: AnalyticsEntryType.room, icon: Icons.chat_bubble_outline, - items: (controller.chats ?? []) + items: (controller.chats) .map( (c) => TabItem( avatar: c.avatar, @@ -45,9 +45,9 @@ class StudentAnalyticsView extends StatelessWidget { return controller.userId != null ? BaseAnalyticsPage( + selectedView: controller.widget.selectedView, pageTitle: pageTitle, tabs: [chatTabData, classTabData], - refreshData: controller.getClassAndChatAnalytics, alwaysSelected: AnalyticsSelected( controller.userId!, AnalyticsEntryType.student, diff --git a/lib/pangea/pages/analytics/time_span_menu_button.dart b/lib/pangea/pages/analytics/time_span_menu_button.dart index df885d6cd..97a2ede4b 100644 --- a/lib/pangea/pages/analytics/time_span_menu_button.dart +++ b/lib/pangea/pages/analytics/time_span_menu_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import '../../enum/time_span.dart'; @@ -16,6 +15,7 @@ class TimeSpanMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { return PopupMenuButton( + offset: const Offset(0, 100), icon: const Icon(Icons.calendar_month_outlined), tooltip: L10n.of(context)!.changeDateRange, initialValue: value, diff --git a/lib/pangea/repo/span_data_repo.dart b/lib/pangea/repo/span_data_repo.dart index e253bb1d0..f06363fd1 100644 --- a/lib/pangea/repo/span_data_repo.dart +++ b/lib/pangea/repo/span_data_repo.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/config/environment.dart'; import 'package:fluffychat/pangea/enum/span_choice_type.dart'; import 'package:fluffychat/pangea/enum/span_data_type.dart'; @@ -80,15 +81,39 @@ class SpanDetailsRepoReqAndRes { bool operator ==(Object other) { if (identical(this, other)) return true; if (other is! SpanDetailsRepoReqAndRes) return false; - - return toJson().toString() == other.toJson().toString(); + if (other.userL1 != userL1) return false; + if (other.userL2 != userL2) return false; + if (other.enableIT != enableIT) return false; + if (other.enableIGC != enableIGC) return false; + if (other.span.choices + ?.firstWhere( + (choice) => choice.type == SpanChoiceType.bestCorrection, + ) + .value != + span.choices + ?.firstWhere( + (choice) => choice.type == SpanChoiceType.bestCorrection, + ) + .value) return false; + return true; } /// Overrides the hashCode getter to generate a hash code for the [SpanDetailsRepoReqAndRes] object. /// Used as keys in response cache in igc_controller. @override int get hashCode { - return toJson().toString().hashCode; + return Object.hashAll([ + userL1.hashCode, + userL2.hashCode, + enableIT.hashCode, + enableIGC.hashCode, + span.choices + ?.firstWhereOrNull( + (choice) => choice.type == SpanChoiceType.bestCorrection, + ) + ?.value + .hashCode, + ]); } } diff --git a/lib/pangea/utils/class_chat_power_levels.dart b/lib/pangea/utils/class_chat_power_levels.dart index de2b0bdeb..a4535924f 100644 --- a/lib/pangea/utils/class_chat_power_levels.dart +++ b/lib/pangea/utils/class_chat_power_levels.dart @@ -15,7 +15,6 @@ class ClassChatPowerLevels { final Map powerLevelOverride = {}; powerLevelOverride['events'] = { EventTypes.spaceChild: 0, - PangeaEventTypes.studentAnalyticsSummary: 0, }; powerLevelOverride['users'] = {}; diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 58c0330b8..47805638e 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -113,7 +113,6 @@ abstract class ClientManager { // #Pangea PangeaEventTypes.classSettings, PangeaEventTypes.rules, - PangeaEventTypes.vocab, PangeaEventTypes.botOptions, EventTypes.RoomTopic, EventTypes.RoomAvatar,