From 12e364a32dbbb919b51e848b4d2a678532715f79 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 3 Jun 2024 11:47:33 -0400 Subject: [PATCH 01/25] move student summary analytics events to analytics rooms --- lib/pages/chat/chat.dart | 27 - lib/pages/new_space/new_space.dart | 2 - lib/pangea/constants/model_keys.dart | 3 + lib/pangea/constants/pangea_event_types.dart | 6 +- .../message_analytics_controller.dart | 802 ++++++++++-------- .../controllers/my_analytics_controller.dart | 376 +++++--- lib/pangea/extensions/client_extension.dart | 23 +- .../extensions/pangea_room_extension.dart | 681 ++++++++------- .../construct_analytics_event.dart | 99 ++- lib/pangea/models/analytics_event.dart | 28 + lib/pangea/models/analytics_model.dart | 11 + lib/pangea/models/choreo_record.dart | 11 +- .../models/constructs_analytics_model.dart | 4 + lib/pangea/models/constructs_event.dart | 52 ++ lib/pangea/models/constructs_model.dart | 257 ++++++ .../models/student_analytics_event.dart | 285 ++++--- .../student_analytics_summary_model.dart | 14 +- .../models/summary_analytics_event.dart | 21 + .../models/summary_analytics_model.dart | 57 ++ .../pages/analytics/analytics_list_tile.dart | 195 +++-- .../pages/analytics/base_analytics.dart | 136 +-- .../pages/analytics/base_analytics_view.dart | 126 ++- .../class_analytics/class_analytics.dart | 117 +-- .../class_analytics/class_analytics_view.dart | 5 +- .../analytics/class_list/class_list.dart | 72 +- .../analytics/class_list/class_list_view.dart | 19 +- .../pages/analytics/construct_list.dart | 82 +- .../student_analytics/student_analytics.dart | 147 +--- .../student_analytics_view.dart | 3 +- .../analytics/time_span_menu_button.dart | 2 +- lib/pangea/utils/class_chat_power_levels.dart | 3 - lib/utils/client_manager.dart | 1 - 32 files changed, 2036 insertions(+), 1631 deletions(-) create mode 100644 lib/pangea/models/analytics_event.dart create mode 100644 lib/pangea/models/analytics_model.dart create mode 100644 lib/pangea/models/constructs_event.dart create mode 100644 lib/pangea/models/constructs_model.dart create mode 100644 lib/pangea/models/summary_analytics_event.dart create mode 100644 lib/pangea/models/summary_analytics_model.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 460de198c..2e54acbfd 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'; @@ -643,34 +642,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/new_space/new_space.dart b/lib/pages/new_space/new_space.dart index a310769cc..835ce6ffe 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.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; @@ -77,7 +76,6 @@ class NewSpaceController extends State { stateKey: '', content: { 'events': { - PangeaEventTypes.studentAnalyticsSummary: 0, EventTypes.spaceChild: 0, }, 'users_default': 0, diff --git a/lib/pangea/constants/model_keys.dart b/lib/pangea/constants/model_keys.dart index ce427f3d3..6787e7b20 100644 --- a/lib/pangea/constants/model_keys.dart +++ b/lib/pangea/constants/model_keys.dart @@ -109,4 +109,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 cfdb7f0d7..02334d7fe 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 f3e2b4674..26639c14f 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -2,9 +2,11 @@ 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/constructs_event.dart'; +import 'package:fluffychat/pangea/models/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'; @@ -13,16 +15,14 @@ import 'package:matrix/matrix.dart'; import '../constants/class_default_values.dart'; import '../extensions/client_extension.dart'; import '../extensions/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 'base_controller.dart'; import 'pangea_controller.dart'; class AnalyticsController extends BaseController { late PangeaController _pangeaController; - final List _cachedModels = []; + final List _cachedAnalyticsModels = []; final List _cachedConstructs = []; AnalyticsController(PangeaController pangeaController) : super() { @@ -57,272 +57,384 @@ 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> allMySummaryAnalytics() async { + final analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + final List allEvents = []; + for (final Room analyticsRoom in analyticsRooms) { + final List? roomEvents = + (await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.summaryAnalytics, + since: currentAnalyticsTimeSpan.cutOffDate, + )) + ?.cast(); + allEvents.addAll(roomEvents ?? []); } - - return Future.wait(classAnalyticFutures); + return allEvents; } - ChartAnalyticsModel? getAnalyticsLocal({ - TimeSpan? timeSpan, - String? classId, - String? studentId, - String? chatId, - bool forceUpdate = false, - bool updateExpired = false, - }) { - timeSpan ??= currentAnalyticsTimeSpan; - final int index = _cachedModels.indexWhere( - (e) => - (e.timeSpan == timeSpan) && - (e.classId == classId) && - (e.studentId == studentId) && - (e.chatId == chatId), - ); - - if (index != -1) { - if ((updateExpired && _cachedModels[index].isExpired) || forceUpdate) { - _cachedModels.removeAt(index); - } else { - return _cachedModels[index].chartAnalyticsModel; - } - } - - return null; - } - - Future getAnalytics({ - 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; - } - - Future myAnalyticsRoom(String langCode) => - _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode); - - Room? studentAnalyticsRoom(String studentId, String langCode) => - _pangeaController.matrixState.client.analyticsRoomLocal( - langCode, - studentId, - ); - - Future> allMyConstructs( - String langCode, { - ConstructType? type, - }) async { - final Room analyticsRoom = await myAnalyticsRoom(langCode); - 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)); - } - - return allConstructs - .where((construct) => construct.content.uses.isNotEmpty) - .toList(); - } - - Future> allSpaceMemberConstructs( + Future> allSpaceMemberAnalytics( Room space, - String langCode, { - ConstructType? type, - }) async { - final List>> constructEventFutures = []; + ) async { + final langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + final List analyticsEvents = []; await space.postLoad(); await space.requestParticipants(); for (final student in space.students) { - final Room? room = _pangeaController.matrixState.client + final Room? analyticsRoom = _pangeaController.matrixState.client .analyticsRoomLocal(langCode, student.id); - if (room != null) constructEventFutures.add(room.allConstructEvents); + if (analyticsRoom != null) { + final List? roomEvents = + (await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.summaryAnalytics, + since: currentAnalyticsTimeSpan.cutOffDate, + )) + ?.cast(); + analyticsEvents.addAll(roomEvents ?? []); + } } - 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), + 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, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + bool forceUpdate = false, + bool updateExpired = false, + }) { + timeSpan ??= currentAnalyticsTimeSpan; + final int index = _cachedAnalyticsModels.indexWhere( + (e) => + (e.timeSpan == timeSpan) && + (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 && _cachedAnalyticsModels[index].isExpired) || + forceUpdate) { + _cachedAnalyticsModels.removeAt(index); + } else { + return _cachedAnalyticsModels[index].chartAnalyticsModel; + } + } + + return null; + } + + void cacheAnalytics({ + required ChartAnalyticsModel chartAnalyticsModel, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + TimeSpan? timeSpan, + }) { + _cachedAnalyticsModels.add( + AnalyticsCacheModel( + timeSpan: timeSpan ?? currentAnalyticsTimeSpan, + chartAnalyticsModel: chartAnalyticsModel, + defaultSelected: defaultSelected, + selected: selected, + ), + ); + } + + List filterStudentAnalytics( + List unfiltered, + String? studentId, + ) { + final List filtered = + List.from(unfiltered); + filtered.removeWhere((e) => e.event.senderId != studentId); + return filtered; + } + + List filterRoomAnalytics( + List unfiltered, + String? roomID, + ) { + List filtered = [...unfiltered]; + 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; + } + + List filterPrivateChatAnalytics( + List unfiltered, + Room? space, + ) { + final List directChatIds = + space?.childrenAndGrandChildrenDirectChatIds ?? []; + List filtered = + List.from(unfiltered); + filtered = filtered.where((e) { + return (e.content).messages.any( + (u) => directChatIds.contains(u.chatId), + ); + }).toList(); + filtered.forEachIndexed( + (i, _) => (filtered[i].content).messages.removeWhere( + (u) => !directChatIds.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 { + 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 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 { + final local = getAnalyticsLocal( + defaultSelected: defaultSelected, + selected: selected, + forceUpdate: forceUpdate, + ); + if (local != null && !forceUpdate) { + return local; + } + + await _pangeaController.matrixState.client.roomsLoading; + Room? space; + 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, + ); + } + } + + final List summaryEvents = + defaultSelected.type == AnalyticsEntryType.space + ? await allSpaceMemberAnalytics(space!) + : await allMySummaryAnalytics(); + + final List filteredAnalytics = + await filterAnalytics( + unfilteredAnalytics: summaryEvents, + defaultSelected: defaultSelected, + space: space, + selected: selected, + ); + + final ChartAnalyticsModel newModel = ChartAnalyticsModel( + timeSpan: currentAnalyticsTimeSpan, + msgs: filteredAnalytics + .map((event) => event.content.messages) + .expand((msgs) => msgs) + .toList(), + ); + + if (local == null) { + 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 //////////////////////////// + + List? _constructs; + bool settingConstructs = false; + + List? get constructs => _constructs; + + Future> allMyConstructs({ + ConstructType? type, + }) async { + final List analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + + List allConstructs = []; + for (final Room analyticsRoom in analyticsRooms) { + final List? roomEvents = + (await analyticsRoom.getAnalyticsEvents( + type: PangeaEventTypes.construct, + )) + ?.cast(); + allConstructs.addAll(roomEvents ?? []); + } + + allConstructs = type == null + ? allConstructs + : allConstructs.where((e) => e.content.type == type).toList(); + + final List adminSpaceRooms = + await _pangeaController.matrixState.client.teacherRoomIds; + for (final construct in allConstructs) { + final lemmaUses = construct.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.uses.removeWhere((u) => adminSpaceRooms.contains(u.chatId)); + } + } + + return allConstructs + .where((construct) => construct.content.uses.isNotEmpty) + .toList(); + } + + Future> allSpaceMemberConstructs( + Room space, { + ConstructType? type, + }) async { + await space.postLoad(); + await space.requestParticipants(); + final String? langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + + if (langCode == null) { + ErrorHandler.logError( + m: "langCode missing in allSpaceMemberConstructs", + data: { + "space": space, + }, + ); + return []; + } + + 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, + )) + ?.cast(); + constructEvents.addAll(roomEvents ?? []); + } + } + + final List spaceChildrenIds = space.spaceChildren + .map((e) => e.roomId) + .where((e) => e != null) + .cast() + .toList(); + + final List allConstructs = []; + for (final constructEvent in constructEvents) { + final lemmaUses = constructEvent.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.uses.removeWhere((u) => !spaceChildrenIds.contains(u.chatId)); + } + + if (constructEvent.content.uses.isNotEmpty) { + allConstructs.add(constructEvent); + } } return type == null @@ -330,51 +442,49 @@ class AnalyticsController extends BaseController { : allConstructs.where((e) => e.content.type == type).toList(); } - 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) { + final lemmaUses = construct.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.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 filtered = + List.from(unfilteredConstructs); + for (final construct in filtered) { + final lemmaUses = construct.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.uses.removeWhere((u) => !directChatIds.contains(u.chatId)); + } + } return filtered; } - List filterSpaceConstructs( - List unfilteredConstructs, + List filterSpaceConstructs( + List unfilteredConstructs, Room space, ) { final List chatIds = space.spaceChildren @@ -383,21 +493,20 @@ 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) { + final lemmaUses = construct.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.uses.removeWhere((u) => !chatIds.contains(u.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, @@ -419,7 +528,7 @@ class AnalyticsController extends BaseController { void cacheConstructs({ required ConstructType constructType, - required List events, + required List events, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, }) { @@ -434,14 +543,13 @@ class AnalyticsController extends BaseController { ); } - Future> getMyConstructs({ + Future> getMyConstructs({ required AnalyticsSelected defaultSelected, required ConstructType constructType, - required String langCode, AnalyticsSelected? selected, }) async { - final List unfilteredConstructs = await allMyConstructs( - langCode, + final List unfilteredConstructs = + await allMyConstructs( type: constructType, ); @@ -451,39 +559,34 @@ 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,10 +598,12 @@ 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), - ); + final lemmaUses = construct.content.uses; + for (final lemmaUse in lemmaUses) { + lemmaUse.uses.removeWhere( + (u) => u.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate), + ); + } } unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty); @@ -512,12 +617,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,14 +631,14 @@ class AnalyticsController extends BaseController { } } - Future?> setConstructs({ + Future?> setConstructs({ required ConstructType constructType, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, bool removeIT = false, bool forceUpdate = false, }) async { - final List? local = getConstructsLocal( + final List? local = getConstructsLocal( timeSpan: currentAnalyticsTimeSpan, constructType: constructType, defaultSelected: defaultSelected, @@ -559,50 +659,31 @@ class AnalyticsController extends BaseController { ); } - 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, - }, - ); - throw "langCode missing in getConstructs"; - } - 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, + ); + } + } + + _constructs = filteredConstructs; if (local == null) { cacheConstructs( @@ -621,7 +702,7 @@ class AnalyticsController extends BaseController { class ConstructCacheEntry { final TimeSpan timeSpan; final ConstructType type; - final List events; + final List events; final AnalyticsSelected defaultSelected; AnalyticsSelected? selected; @@ -634,20 +715,18 @@ class ConstructCacheEntry { }); } -class CacheModel { +class AnalyticsCacheModel { TimeSpan timeSpan; ChartAnalyticsModel chartAnalyticsModel; - String? classId; - String? chatId; - String? studentId; + final AnalyticsSelected defaultSelected; + AnalyticsSelected? selected; late DateTime _createdAt; - CacheModel({ + AnalyticsCacheModel({ required this.timeSpan, - required this.classId, required this.chartAnalyticsModel, - required this.chatId, - required this.studentId, + required this.defaultSelected, + this.selected, }) { _createdAt = DateTime.now(); } @@ -656,10 +735,3 @@ class CacheModel { DateTime.now().difference(_createdAt).inMinutes > ClassDefaultValues.minutesDelayToMakeNewChartAnalytics; } - -// class ListTotals { -// String listName; -// ConstructUses vocabUse; - -// ListTotals({required this.listName, required this.vocabUse}); -// } diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index eb3969bd1..52977f14f 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,133 +1,312 @@ import 'dart:async'; import 'dart:developer'; +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/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics_event.dart'; +import 'package:fluffychat/pangea/models/constructs_event.dart'; +import 'package:fluffychat/pangea/models/constructs_model.dart'; import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/models/summary_analytics_event.dart'; +import 'package:fluffychat/pangea/models/summary_analytics_model.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import '../extensions/client_extension.dart'; import '../extensions/pangea_room_extension.dart'; import '../models/constructs_analytics_model.dart'; -import '../models/student_analytics_event.dart'; -class MyAnalyticsController { +class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; MyAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; } - String? get _userId => _pangeaController.matrixState.client.userID; + final List analyticsEventTypes = [ + PangeaEventTypes.summaryAnalytics, + PangeaEventTypes.construct, + ]; - //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; - } - - 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 - - final List events = await analyticsEvents(spaces); - - for (final event in events) { - if (event != null) { - event.handleNewMessage(messageRecord, isEdit: isEdit); - } - } - } catch (err) { - debugger(when: kDebugMode); - } - } - - Future> analyticsEvents( - List spaces, + Future sendAllAnalyticsEvents( + Room analyticsRoom, ) async { - final List> events = []; - if (_userId != null) { - for (final space in spaces) { - events.add(space.getStudentAnalytics(_userId!)); - } + final String? langCode = analyticsRoom.madeForLang; + if (langCode == null) { + debugPrint("no lang code found for analytics room: ${analyticsRoom.id}"); + return; } - return Future.wait(events); - } - Future> allMyAnalyticsEvents() async => - analyticsEvents( - await _pangeaController - .matrixState.client.classesAndExchangesImStudyingIn, + final Map prevEvents = {}; + for (final type in analyticsEventTypes) { + final prevEvent = await analyticsRoom.getLastAnalyticsEvent(type); + prevEvents[type] = prevEvent; + } + + final List lastUpdates = prevEvents.values + .map((e) => e?.content.lastUpdated) + .cast() + .toList(); + + DateTime? earliestLastUpdated; + if (!lastUpdates.any((updated) => updated == null)) { + earliestLastUpdated = lastUpdates.reduce( + (min, e) => e!.isBefore(min!) ? e : min, + ); + } + + final List analyticsContent = []; + final List constructsContent = []; + + for (final Room chat in _studentChats) { + final String? chatLangCode = + _pangeaController.languageController.activeL2Code(roomID: chat.id); + if (chatLangCode != langCode) continue; + + final List recentMsgs = + await chat.myMessageEventsInChat( + since: earliestLastUpdated, ); - Future saveConstructsMixed( - List allUses, - String langCode, { - bool isEdit = false, - }) async { - 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); + analyticsContent.addAll( + formatAnalyticsContent( + recentMsgs, + prevEvents[PangeaEventTypes.summaryAnalytics] + as SummaryAnalyticsEvent?, + ), + ); - 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, + constructsContent.addAll( + formatConstructsContent( + recentMsgs, + prevEvents[PangeaEventTypes.construct] as ConstructAnalyticsEvent?, + ), + ); + } + + if (analyticsContent.isNotEmpty) { + final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel( + messages: analyticsContent, + lastUpdated: DateTime.now(), + prevEventId: + prevEvents[PangeaEventTypes.summaryAnalytics]?.event.eventId, + prevLastUpdated: + prevEvents[PangeaEventTypes.summaryAnalytics]?.content.lastUpdated, + ); + await analyticsRoom.sendEvent( + analyticsModel.toJson(), + type: PangeaEventTypes.summaryAnalytics, + ); + } + + if (constructsContent.isNotEmpty) { + final Map> lemmasUses = {}; + for (final use in constructsContent) { + if (use.lemma == null) { + debugPrint("use has no lemma!"); + continue; + } + lemmasUses[use.lemma!] ??= []; + lemmasUses[use.lemma]!.add(use); + } + + final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel( + type: ConstructType.grammar, + uses: lemmasUses.entries + .map( + (entry) => LemmaConstructsModel( + lemma: entry.key, + uses: entry.value, + ), + ) + .toList(), + lastUpdated: DateTime.now(), + prevEventId: prevEvents[PangeaEventTypes.construct]?.event.eventId, + prevLastUpdated: + prevEvents[PangeaEventTypes.construct]?.content.lastUpdated, + ); + + await analyticsRoom.sendEvent( + constructsModel.toJson(), + type: PangeaEventTypes.construct, + ); + } + } + + List formatAnalyticsContent( + List recentMsgs, + SummaryAnalyticsEvent? prevEvent, + ) { + List filtered = List.from(recentMsgs); + if (prevEvent?.content.lastUpdated != null) { + filtered = recentMsgs + .where( + (msg) => msg.event.originServerTs.isAfter( + prevEvent!.content.lastUpdated!, + ), + ) + .toList(); + } + + final List addedMsgIds = + prevEvent?.content.messages.map((msg) => msg.eventId).toList() ?? []; + + filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId)); + + final List records = filtered + .map( + (msg) => RecentMessageRecord( + eventId: msg.eventId, + chatId: msg.room.id, + useType: msg.useType, + time: msg.originServerTs, ), - ); - } + ) + .toList(); - await Future.wait(saveFutures); - } catch (err, s) { + return records; + } + + List formatConstructsContent( + List recentMsgs, + ConstructAnalyticsEvent? prevEvent, + ) { + List filtered = List.from(recentMsgs); + if (prevEvent?.content.lastUpdated != null) { + filtered = recentMsgs + .where( + (msg) => msg.event.originServerTs.isAfter( + prevEvent!.content.lastUpdated!, + ), + ) + .toList(); + } + + final List addedMsgIds = prevEvent?.content.uses + .map((lemmause) => lemmause.uses.map((use) => use.msgId)) + .expand((element) => element) + .where((element) => element != null) + .cast() + .toList() ?? + []; + + filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId)); + + final List uses = filtered + .map( + (msg) => msg.originalSent?.choreo?.toGrammarConstructUse( + msg.eventId, + msg.room.id, + msg.originServerTs, + ), + ) + .where((element) => element != null) + .cast>() + .expand((element) => element) + .toList(); + + return uses; + } + + 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); - if (!kDebugMode) rethrow; - ErrorHandler.logError(e: err, s: s); + return []; + } + } + + List _studentSpaces = []; + + Future setStudentSpaces() async { + if (_studentSpaces.isNotEmpty) return; + _studentSpaces = await _pangeaController + .matrixState.client.classesAndExchangesImStudyingIn; + } + + List get studentSpaces { + try { + if (_studentSpaces.isNotEmpty) return _studentSpaces; + setStudentSpaces(); + return _studentSpaces; + } catch (err) { + debugger(when: kDebugMode); + return []; + } + } + + // 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> createMissingAnalyticsRoom() 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; + } + + Future updateAnalytics() async { + await setStudentChats(); + final List analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + analyticsRooms.addAll(await createMissingAnalyticsRoom()); + + for (final Room analyticsRoom in analyticsRooms) { + await sendAllAnalyticsEvents(analyticsRoom); } } // used to aggregate ConstructEvents, from multiple senders (students) with the same lemma List aggregateConstructData( - List constructs, + List constructs, ) { - final Map> lemmasToConstructs = {}; + final Map> lemmasToConstructs = {}; for (final construct in constructs) { - lemmasToConstructs[construct.content.lemma] ??= []; - lemmasToConstructs[construct.content.lemma]!.add(construct); + for (final lemmaUses in construct.content.uses) { + lemmasToConstructs[lemmaUses.lemma] ??= []; + lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses); + } } final List aggregatedConstructs = []; for (final lemmaToConstructs in lemmasToConstructs.entries) { - final List lemmaConstructs = lemmaToConstructs.value; + final List lemmaConstructs = + lemmaToConstructs.value; final AggregateConstructUses aggregatedData = AggregateConstructUses( - constructs: lemmaConstructs, + lemmaUses: lemmaConstructs, ); aggregatedConstructs.add(aggregatedData); } @@ -136,24 +315,23 @@ class MyAnalyticsController { } class AggregateConstructUses { - final List _constructs; + final List _lemmaUses; - AggregateConstructUses({required List constructs}) - : _constructs = constructs; + AggregateConstructUses({required List lemmaUses}) + : _lemmaUses = lemmaUses; String get lemma { assert( - _constructs.isNotEmpty && - _constructs.every( - (construct) => - construct.content.lemma == _constructs.first.content.lemma, + _lemmaUses.isNotEmpty && + _lemmaUses.every( + (construct) => construct.lemma == _lemmaUses.first.lemma, ), ); - return _constructs.first.content.lemma; + return _lemmaUses.first.lemma; } - List get uses => _constructs - .map((construct) => construct.content.uses) + List get uses => _lemmaUses + .map((lemmaUse) => lemmaUse.uses) .expand((element) => element) .toList(); } diff --git a/lib/pangea/extensions/client_extension.dart b/lib/pangea/extensions/client_extension.dart index 47d67c0b8..b1470c122 100644 --- a/lib/pangea/extensions/client_extension.dart +++ b/lib/pangea/extensions/client_extension.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; @@ -11,8 +9,6 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; -import '../utils/p_store.dart'; - extension PangeaClient on Client { List get classes => rooms.where((e) => e.isPangeaClass).toList(); @@ -97,23 +93,6 @@ extension PangeaClient on Client { return teachers; } - 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); - } - } - // get analytics room matching targetlanguage // if not present, create it and invite teachers of that language // set description to let people know what the hell it is @@ -143,7 +122,7 @@ extension PangeaClient on Client { }); if (analyticsRoom != null && analyticsRoom.membership == Membership.invite) { - debugger(when: kDebugMode); + // debugger(when: kDebugMode); analyticsRoom .join() .onError( diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index 4d08f0b62..b6084245f 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -6,8 +6,11 @@ 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_event.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; import 'package:fluffychat/pangea/models/class_model.dart'; +import 'package:fluffychat/pangea/models/constructs_event.dart'; +import 'package:fluffychat/pangea/models/summary_analytics_event.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -22,15 +25,9 @@ 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.dart'; extension PangeaRoom on Room { @@ -303,6 +300,12 @@ extension PangeaRoom on Room { bool isMadeByUser(String userId) => getState(EventTypes.RoomCreate)?.senderId == userId; + 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 || @@ -328,55 +331,81 @@ extension PangeaRoom on Room { return canonicalAlias.replaceAll(":$domainString", "").replaceAll("#", ""); } - StudentAnalyticsEvent? _getStudentAnalyticsLocal(String studentId) { - if (!isSpace) { - debugger(when: kDebugMode); - ErrorHandler.logError( - m: "calling getStudentAnalyticsLocal on non-space room", - s: StackTrace.current, - ); - return null; - } + // StudentAnalyticsEvent? _getStudentAnalyticsLocal() { + // if (!isAnalyticsRoom) { + // debugger(when: kDebugMode); + // ErrorHandler.logError( + // m: "calling getStudentAnalyticsLocal on non-analytics room", + // s: StackTrace.current, + // ); + // return null; + // } - final Event? matrixEvent = getState( - PangeaEventTypes.studentAnalyticsSummary, - studentId, - ); + // final Event? matrixEvent = getState(PangeaEventTypes.summaryAnalytics); - return matrixEvent != null - ? StudentAnalyticsEvent(event: matrixEvent) - : null; - } + // 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); + // Future> lemmasLastUpdated() async { + // try { + // if (!isAnalyticsRoom) { + // debugger(when: kDebugMode); + // throw Exception( + // "calling lemmasLastUpdated on non-analytics room", + // ); + // } - if (localEvent == null) { - await postLoad(); - localEvent = _getStudentAnalyticsLocal(studentId); - } + // await postLoad(); + // final entries = states[PangeaEventTypes.vocab]?.entries.toList(); + // if (entries != null && entries.isNotEmpty) { + // final Map resultMap = {}; + // for (final entry in entries) { + // // migration - don't count uses without unique IDs + // if (ConstructEvent(event: entry.value) + // .content + // .uses + // .any((use) => use.id != null)) { + // resultMap[entry.key] = entry.value.originServerTs; + // } + // } + // return resultMap; + // } + // return {}; + // } catch (err) { + // debugger(when: kDebugMode); + // rethrow; + // } + // } - if (studentId == client.userID && localEvent == null) { - final Event? matrixEvent = await _createStudentAnalyticsEvent(); - if (matrixEvent != null) { - localEvent = StudentAnalyticsEvent(event: matrixEvent); - } - } + // Future getStudentAnalyticsEvent({ + // bool forcedUpdate = false, + // }) async { + // try { + // if (!isAnalyticsRoom) { + // debugger(when: kDebugMode); + // throw Exception( + // "calling getStudentAnalyticsLocal on non-analytics room", + // ); + // } - return localEvent; - } catch (err) { - debugger(when: kDebugMode); - rethrow; - } - } + // await postLoad(); + // StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal(); + + // if (isRoomOwner && localEvent == null) { + // final Event? matrixEvent = await _createStudentAnalyticsEvent(); + // if (matrixEvent != null) { + // localEvent = StudentAnalyticsEvent(event: matrixEvent); + // } + // } + + // return localEvent; + // } catch (err) { + // debugger(when: kDebugMode); + // rethrow; + // } + // } void checkClass() { if (!isSpace) { @@ -414,201 +443,109 @@ extension PangeaRoom on Room { : participants; } - /// 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"); - } + // Future _createStudentAnalyticsEvent() async { + // try { + // if (!isAnalyticsRoom) { + // debugger(when: kDebugMode); + // throw Exception( + // "calling _createStudentAnalyticsEvent on non-analytics room", + // ); + // } - 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); + // 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"); + // } - 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; - } - } + // final String eventId = await client.setRoomStateWithKey( + // id, + // PangeaEventTypes.studentAnalyticsSummary, + // '', + // StudentAnalyticsSummary( + // lastUpdated: null, + // messages: [], + // ).toJson(), + // ); + // final Event? event = await getEventById(eventId); - /// 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 (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; + // } + // } - 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); - } - } - - Future> get _messageListForAllChildChats async { - try { - if (!isSpace) return []; - final List spaceChats = spaceChildren - .where((e) => e.roomId != null) - .map((e) => client.getRoomById(e.roomId!)) - .where((element) => element != null) - .cast() - .where((element) => !element.isSpace) - .toList(); - - final List>> msgListFutures = []; - for (final chat in spaceChats) { - msgListFutures.add(chat._messageListForChat); - } - final List> msgLists = - await Future.wait(msgListFutures); - - final List joined = []; - for (final msgList in msgLists) { - joined.addAll(msgList); - } - return joined; - } catch (err) { - // debugger(when: kDebugMode); - rethrow; - } - } - - Future> get _messageListForChat async { + Future> myMessageEventsInChat({ + DateTime? since, + }) async { try { int numberOfSearches = 0; - if (isSpace) { throw Exception( "In messageListForChat with room that is not a chat", ); } final Timeline timeline = await getTimeline(); - while (timeline.canRequestHistory && numberOfSearches < 50) { - await timeline.requestHistory(historyCount: 100); + try { + await timeline.requestHistory(); + } catch (err) { + break; + } numberOfSearches += 1; - } - if (timeline.canRequestHistory) { - debugger(when: kDebugMode); + if (timeline.events.any( + (event) => event.originServerTs.isAfter(since ?? DateTime.now()), + )) { + break; + } } - final List msgs = []; - for (final event in timeline.events) { - if (event.senderId == client.userID && - event.type == EventTypes.Message && - event.content['msgtype'] == MessageTypes.Text) { + final List msgs = []; + for (Event event in timeline.events) { + final bool hasAnalytics = (event.senderId == client.userID) && + (event.type == EventTypes.Message) && + (event.content['msgtype'] == MessageTypes.Text && + !(event.relationshipType == RelationshipTypes.edit)); + if (hasAnalytics && + (since == null || event.originServerTs.isAfter(since))) { + if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) { + event = event + .aggregatedEvents( + timeline, + RelationshipTypes.edit, + ) + .sorted( + (a, b) => b.originServerTs.compareTo(a.originServerTs), + ) + .firstOrNull ?? + event; + } final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( event: event, timeline: timeline, ownMessage: true, ); - msgs.add( - RecentMessageRecord( - eventId: event.eventId, - chatId: id, - useType: pMsgEvent.useType, - time: event.originServerTs, - ), - ); + msgs.add(pMsgEvent); } } return msgs; @@ -673,139 +610,152 @@ extension PangeaRoom 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; + // } bool get isRoomOwner => getState(EventTypes.RoomCreate)?.senderId == client.userID; - 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> removeEdittedLemmas( - 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> removeEdittedLemmas( + // 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 saveConstructUsesSameLemma( - String lemma, - ConstructType type, - List lemmaUses, { - bool isEdit = false, - }) async { - final ConstructEvent? localEvent = _vocabEventLocal(lemma); + // Future saveConstructUsesSameLemma( + // String lemma, + // ConstructType type, + // List lemmaUses, { + // bool isEdit = false, + // }) async { + // final ConstructEvent? localEvent = vocabEventLocal(lemma); - if (isEdit) { - lemmaUses = await removeEdittedLemmas(lemmaUses); - } + // if (isEdit) { + // lemmaUses = await removeEdittedLemmas(lemmaUses); + // } - if (localEvent == null) { - await client.setRoomStateWithKey( - id, - PangeaEventTypes.vocab, - lemma, - ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(), - ); - } else { - localEvent.addAll(lemmaUses); - await updateStateEvent(localEvent.event); - } - } + // // final waitForUpdate = client.onRoomState.stream.firstWhere( + // // (Event event) => + // // event.type == PangeaEventTypes.vocab && event.stateKey == lemma, + // // ); - Future> get allConstructEvents async { - await postLoad(); - return states[PangeaEventTypes.vocab] - ?.values - .map((Event event) => ConstructEvent(event: event)) - .toList() - .cast() ?? - []; - } + // if (localEvent == null) { + // await client.setRoomStateWithKey( + // id, + // PangeaEventTypes.vocab, + // lemma, + // ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(), + // ); + // await postLoad(); + // } else { + // // migration - remove uses without unique IDs, + // // this is used to prevent duplicate saves + // localEvent.content.uses.removeWhere((use) => use.id == null); + // localEvent.addAll(lemmaUses); + // await updateStateEvent(localEvent.event); + // } - 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); + // // await waitForUpdate; + // } - 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> 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 { + // 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); + + // 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; + // } + // } /// update state event and return eventId - Future updateStateEvent(Event stateEvent) { + Future updateStateEvent(Event stateEvent) async { if (stateEvent.stateKey == null) { throw Exception("stateEvent.stateKey is null"); } - return client.setRoomStateWithKey( + final String resp = await client.setRoomStateWithKey( id, stateEvent.type, stateEvent.stateKey!, stateEvent.content, ); + await postLoad(); + return resp; } bool canIAddSpaceChild(Room? room) { @@ -872,15 +822,9 @@ extension PangeaRoom on Room { final Map? currentPowerContent = currentPower?.content["events"] as Map?; final spaceChildPower = currentPowerContent?[EventTypes.spaceChild]; - final studentAnalyticsPower = - currentPowerContent?[PangeaEventTypes.studentAnalyticsSummary]; - if ((spaceChildPower == null || studentAnalyticsPower == null) && - currentPowerContent != null) { + if (spaceChildPower == null && currentPowerContent != null) { currentPowerContent["events"][EventTypes.spaceChild] = 0; - currentPowerContent["events"] - [PangeaEventTypes.studentAnalyticsSummary] = 0; - await client.setRoomStateWithKey( id, EventTypes.RoomPowerLevels, @@ -1260,4 +1204,83 @@ extension PangeaRoom on Room { if (firstLanguageSettings?.targetLanguage == null) return; await client.getMyAnalyticsRoom(firstLanguageSettings!.targetLanguage); } + + Future getLastAnalyticsEvent( + String type, + ) async { + final Timeline timeline = await getTimeline(); + int requests = 0; + Event? lastEvent = timeline.events.firstWhereOrNull( + (event) => event.type == type, + ); + + while (requests < 10 && timeline.canRequestHistory && lastEvent == null) { + await timeline.requestHistory(); + lastEvent = timeline.events.firstWhereOrNull( + (event) => event.type == type, + ); + requests++; + } + + if (lastEvent == null) return null; + + switch (type) { + case PangeaEventTypes.summaryAnalytics: + return SummaryAnalyticsEvent(event: lastEvent); + case PangeaEventTypes.construct: + return ConstructAnalyticsEvent(event: lastEvent); + } + + return null; + } + + Future getPrevAnalyticsEvent( + AnalyticsEvent analyticsEvent, + ) async { + if (analyticsEvent.content.prevEventId == null) { + return null; + } + final Event? prevEvent = await getEventById( + analyticsEvent.content.prevEventId!, + ); + if (prevEvent == null) return null; + + switch (analyticsEvent.event.type) { + case PangeaEventTypes.summaryAnalytics: + return SummaryAnalyticsEvent(event: prevEvent); + case PangeaEventTypes.construct: + return ConstructAnalyticsEvent(event: prevEvent); + } + + return null; + // } catch (err) { + // debugger(when: kDebugMode); + // return null; + // } + } + + Future?> getAnalyticsEvents({ + required String type, + DateTime? since, + }) async { + final AnalyticsEvent? mostRecentEvent = await getLastAnalyticsEvent(type); + if (mostRecentEvent == null) return null; + final List events = [mostRecentEvent]; + + bool getAllEvents() => + since == null && events.last.content.prevEventId == null; + + bool reachedUpdated() => + since != null && + (events.last.content.lastUpdated?.isBefore(since) ?? true); + + while (getAllEvents() || !reachedUpdated()) { + final AnalyticsEvent? prevEvent = await getPrevAnalyticsEvent( + events.last, + ); + if (prevEvent == null) break; + events.add(prevEvent); + } + return events; + } } 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_event.dart b/lib/pangea/models/analytics_event.dart new file mode 100644 index 000000000..0887354cf --- /dev/null +++ b/lib/pangea/models/analytics_event.dart @@ -0,0 +1,28 @@ +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/models/analytics_model.dart'; +import 'package:fluffychat/pangea/models/constructs_model.dart'; +import 'package:fluffychat/pangea/models/summary_analytics_model.dart'; +import 'package:matrix/matrix.dart'; + +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!; + } +} diff --git a/lib/pangea/models/analytics_model.dart b/lib/pangea/models/analytics_model.dart new file mode 100644 index 000000000..7e376aa4e --- /dev/null +++ b/lib/pangea/models/analytics_model.dart @@ -0,0 +1,11 @@ +abstract class AnalyticsModel { + DateTime? lastUpdated; + String? prevEventId; + DateTime? prevLastUpdated; + + AnalyticsModel({ + this.lastUpdated, + this.prevEventId, + this.prevLastUpdated, + }); +} diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index d3612f8f4..711b30dc2 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -2,6 +2,7 @@ import 'dart:convert'; 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'; @@ -210,9 +211,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 +226,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/constructs_analytics_model.dart b/lib/pangea/models/constructs_analytics_model.dart index a62145485..b45da25e6 100644 --- a/lib/pangea/models/constructs_analytics_model.dart +++ b/lib/pangea/models/constructs_analytics_model.dart @@ -153,6 +153,7 @@ class OneConstructUse { String chatId; String? msgId; DateTime timeStamp; + String? id; OneConstructUse({ required this.useType, @@ -162,6 +163,7 @@ class OneConstructUse { required this.form, required this.msgId, required this.constructType, + this.id, }); factory OneConstructUse.fromJson(Map json) { @@ -176,6 +178,7 @@ class OneConstructUse { constructType: json['constructType'] != null ? ConstructTypeUtil.fromString(json['constructType']) : null, + id: json['id'], ); } @@ -191,6 +194,7 @@ class OneConstructUse { if (!condensed && constructType != null) { data['constructType'] = constructType!.string; } + if (id != null) data['id'] = id; return data; } diff --git a/lib/pangea/models/constructs_event.dart b/lib/pangea/models/constructs_event.dart new file mode 100644 index 000000000..2c81bf949 --- /dev/null +++ b/lib/pangea/models/constructs_event.dart @@ -0,0 +1,52 @@ +import 'package:fluffychat/pangea/models/analytics_event.dart'; +import 'package:fluffychat/pangea/models/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; + } + + // 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(), + // ); + // } + // } +} diff --git a/lib/pangea/models/constructs_model.dart b/lib/pangea/models/constructs_model.dart new file mode 100644 index 000000000..287d7512e --- /dev/null +++ b/lib/pangea/models/constructs_model.dart @@ -0,0 +1,257 @@ +import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/models/analytics_model.dart'; +import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; +import 'package:flutter/material.dart'; + +import '../enum/construct_type_enum.dart'; + +class ConstructAnalyticsModel extends AnalyticsModel { + ConstructType type; + List uses; + + ConstructAnalyticsModel({ + required this.type, + this.uses = const [], + super.lastUpdated, + super.prevEventId, + super.prevLastUpdated, + }); + + static const _lastUpdatedKey = "lupt"; + static const _usesKey = "uses"; + + factory ConstructAnalyticsModel.fromJson(Map json) { + // try { + // debugger( + // when: + // kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null), + // ); + return ConstructAnalyticsModel( + // 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']), + lastUpdated: json[_lastUpdatedKey] != null + ? DateTime.parse(json[_lastUpdatedKey]) + : null, + prevEventId: json[ModelKey.prevEventId], + prevLastUpdated: json[ModelKey.prevLastUpdated] != null + ? DateTime.parse(json[ModelKey.prevLastUpdated]) + : null, + uses: json[_usesKey] + .values + .map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses)) + .cast() + .toList(), + ); + // } catch (err) { + // debugger(when: kDebugMode); + // rethrow; + // } + } + + toJson() { + final Map usesMap = {}; + for (final use in uses) { + debugPrint("use: $use"); + usesMap[use.lemma] = use.toJson(); + } + + return { + // ModelKey.lemma: lemma, + // 'uses': uses.map((use) => use.toJson()).toList(), + 'type': type.string, + _lastUpdatedKey: lastUpdated?.toIso8601String(), + _usesKey: usesMap, + ModelKey.prevEventId: prevEventId, + ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(), + }; + } + + // void addUsesByUseType(List uses) { + // for (final use in uses) { + // if (use.lemma != lemma) { + // throw Exception('lemma mismatch'); + // } + // uses.add(use); + // } + // } +} + +class LemmaConstructsModel { + String lemma; + List uses; + + LemmaConstructsModel({ + required this.lemma, + this.uses = const [], + }); + + factory LemmaConstructsModel.fromJson(Map json) { + return LemmaConstructsModel( + lemma: json[ModelKey.lemma], + uses: (json['uses'] ?? [] as Iterable) + .map( + (use) => use != null ? OneConstructUse.fromJson(use) : null, + ) + .where((element) => element != null) + .cast() + .toList(), + ); + } + + Map toJson() { + return { + ModelKey.lemma: lemma, + 'uses': uses.map((use) => use.toJson()).toList(), + }; + } +} + +enum ConstructUseType { + /// produced in chat by user, igc was run, and we've judged it to be a correct use + wa, + + /// produced in chat by user, igc was run, and we've judged it to be a incorrect use + /// Note: if the IGC match is ignored, this is not counted as an incorrect use + ga, + + /// produced in chat by user and igc was not run + unk, + + /// selected correctly in IT flow + corIt, + + /// encountered as IT distractor and correctly ignored it + ignIt, + + /// encountered as it distractor and selected it + incIt, + + /// encountered in igc match and ignored match + ignIGC, + + /// selected correctly in IGC flow + corIGC, + + /// encountered as distractor in IGC flow and selected it + incIGC, +} + +extension on ConstructUseType { + String get string { + switch (this) { + case ConstructUseType.ga: + return 'ga'; + case ConstructUseType.wa: + return 'wa'; + case ConstructUseType.corIt: + return 'corIt'; + case ConstructUseType.incIt: + return 'incIt'; + case ConstructUseType.ignIt: + return 'ignIt'; + case ConstructUseType.ignIGC: + return 'ignIGC'; + case ConstructUseType.corIGC: + return 'corIGC'; + case ConstructUseType.incIGC: + return 'incIGC'; + case ConstructUseType.unk: + return 'unk'; + } + } + + IconData get icon { + switch (this) { + case ConstructUseType.ga: + return Icons.check; + case ConstructUseType.wa: + return Icons.thumb_up_sharp; + case ConstructUseType.corIt: + return Icons.check; + case ConstructUseType.incIt: + return Icons.close; + case ConstructUseType.ignIt: + return Icons.close; + case ConstructUseType.ignIGC: + return Icons.close; + case ConstructUseType.corIGC: + return Icons.check; + case ConstructUseType.incIGC: + return Icons.close; + case ConstructUseType.unk: + return Icons.help; + } + } +} + +// class OneConstructUse { +// String? lemma; +// ConstructType? constructType; +// String? form; +// ConstructUseType useType; +// String chatId; +// String? msgId; +// DateTime timeStamp; +// String? id; + +// OneConstructUse({ +// required this.useType, +// required this.chatId, +// required this.timeStamp, +// required this.lemma, +// required this.form, +// required this.msgId, +// required this.constructType, +// this.id, +// }); + +// factory OneConstructUse.fromJson(Map json) { +// return OneConstructUse( +// useType: ConstructUseType.values +// .firstWhere((e) => e.string == json['useType']), +// chatId: json['chatId'], +// timeStamp: DateTime.parse(json['timeStamp']), +// lemma: json['lemma'], +// form: json['form'], +// msgId: json['msgId'], +// constructType: json['constructType'] != null +// ? ConstructTypeUtil.fromString(json['constructType']) +// : null, +// id: json['id'], +// ); +// } + +// Map toJson([bool condensed = true]) { +// final Map data = { +// 'useType': useType.string, +// 'chatId': chatId, +// 'timeStamp': timeStamp.toIso8601String(), +// 'form': form, +// 'msgId': msgId, +// }; +// if (!condensed && lemma != null) data['lemma'] = lemma!; +// if (!condensed && constructType != null) { +// data['constructType'] = constructType!.string; +// } +// if (id != null) data['id'] = id; + +// return data; +// } + +// Room? getRoom(Client client) { +// return client.getRoomById(chatId); +// } + +// Future getEvent(Client client) async { +// final Room? room = getRoom(client); +// if (room == null || msgId == null) return null; +// return room.getEventById(msgId!); +// } +// } diff --git a/lib/pangea/models/student_analytics_event.dart b/lib/pangea/models/student_analytics_event.dart index 1884b43b4..7030d7421 100644 --- a/lib/pangea/models/student_analytics_event.dart +++ b/lib/pangea/models/student_analytics_event.dart @@ -1,161 +1,164 @@ -import 'dart:developer'; +// import 'dart:developer'; -import 'package:fluffychat/pangea/constants/class_default_values.dart'; -import 'package:fluffychat/pangea/extensions/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 'package:fluffychat/pangea/extensions/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'; +// import '../constants/pangea_event_types.dart'; +// import 'chart_analytics_model.dart'; -class StudentAnalyticsEvent { - late Event _event; - StudentAnalyticsSummary? _contentCache; - List _messagesToSave = []; +// 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; +// StudentAnalyticsEvent({required Event event}) { +// if (event.type != PangeaEventTypes.studentAnalyticsSummary) { +// throw Exception( +// "${event.type} should not be used to make a StudentAnalyticsEvent", +// ); +// } +// _event = event; +// _messagesToSave = []; +// } - _messagesToSave = []; - } +// Event get event => _event; - Room get classRoom => _event.room; +// StudentAnalyticsSummary get content { +// _contentCache ??= StudentAnalyticsSummary.fromJson(event.content); +// return _contentCache!; +// } - Event get event => _event; +// Future removeEdittedMessages( +// RecentMessageRecord message, +// ) async { +// final List removeIds = await event.room.client.getEditHistory( +// message.chatId, +// message.eventId, +// ); +// if (removeIds.isEmpty) return; +// _messagesToSave.removeWhere( +// (msg) => removeIds.any((e) => e == msg.eventId), +// ); +// content.removeEdittedMessages( +// event.room.client, +// removeIds, +// ); +// } - StudentAnalyticsSummary get content { - _contentCache ??= StudentAnalyticsSummary.fromJson(event.content); - return _contentCache!; - } +// // Future handleNewMessage( +// // RecentMessageRecord message, { +// // isEdit = false, +// // }) async { +// // if (isEdit) { +// // await removeEdittedMessages(message); +// // } +// // // _addMessage(message); +// // _messagesToSave.add(message); +// // debugPrint("messages to save is now: ${_messagesToSave.length}"); - 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, - ); - } +// // if (DateTime.now().difference(content.lastUpdated).inMinutes > +// // ClassDefaultValues.minutesDelayToUpdateMyAnalytics) { +// // _updateStudentAnalytics(); +// // } +// // } - 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; - } +// // Future bulkUpdate(List messages) async { +// // // if (event.room.client.userID != _event.stateKey) { +// // // debugger(when: kDebugMode); +// // // ErrorHandler.logError( +// // // m: "should not be in bulkUpdate ${event.room.client.userID} != ${_event.stateKey}", +// // // ); +// // // return; +// // // } +// // for (final message in messages) { +// // await removeEdittedMessages(message); +// // } - if (isEdit) { - await removeEdittedMessages(message); - } - _addMessage(message); +// // _messagesToSave.addAll(messages); +// // await _updateStudentAnalytics(); +// // } - if (DateTime.now().difference(content.lastUpdated).inMinutes > - ClassDefaultValues.minutesDelayToUpdateMyAnalytics) { - _updateStudentAnalytics(); - } - } +// // Future _updateStudentAnalytics() async { +// // content.lastUpdated = DateTime.now(); +// // content.addAll(_messagesToSave); +// // _clearMessages(); - 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); - } +// // await event.room.client.setRoomStateWithKey( +// // event.room.id, +// // _event.type, +// // '', +// // content.toJson(), +// // ); +// // } - _messagesToSave.addAll(messages); - _updateStudentAnalytics(); - } +// Future updateStudentAnalytics() async { +// content.lastUpdated = DateTime.now(); +// // content.addAll(_messagesToSave); +// // _clearMessages(); - Future _updateStudentAnalytics() async { - content.lastUpdated = DateTime.now(); - content.addAll(_messagesToSave); - debugPrint("updating student analytics"); - _clearMessages(); +// await event.room.client.setRoomStateWithKey( +// event.room.id, +// _event.type, +// '', +// content.toJson(), +// ); +// await event.room.postLoad(); +// } - 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 +// } - _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 +// } - _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 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; +// } - 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; - } -} +// bool isAlreadyAdded(RecentMessageRecord message) { +// return content.messages.any( +// (element) => element.eventId == message.eventId, +// ); +// } +// } diff --git a/lib/pangea/models/student_analytics_summary_model.dart b/lib/pangea/models/student_analytics_summary_model.dart index 69d237ea9..7ed617d91 100644 --- a/lib/pangea/models/student_analytics_summary_model.dart +++ b/lib/pangea/models/student_analytics_summary_model.dart @@ -58,7 +58,7 @@ class RecentMessageRecord { class StudentAnalyticsSummary { late List _messages; - DateTime lastUpdated; + DateTime? lastUpdated; StudentAnalyticsSummary({ required List messages, @@ -69,11 +69,7 @@ class StudentAnalyticsSummary { 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 { + if (!(_messages.any((element) => element.eventId == msg.eventId))) { _messages.add(msg); } } @@ -92,7 +88,7 @@ class StudentAnalyticsSummary { Map toJson() => { _messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()), - _lastUpdatedKey: lastUpdated.toIso8601String(), + _lastUpdatedKey: lastUpdated?.toIso8601String(), }; factory StudentAnalyticsSummary.fromJson(json) { @@ -110,7 +106,9 @@ class StudentAnalyticsSummary { } return StudentAnalyticsSummary( messages: savedMessages, - lastUpdated: DateTime.parse(json[_lastUpdatedKey]), + lastUpdated: json[_lastUpdatedKey] != null + ? DateTime.parse(json[_lastUpdatedKey]) + : null, ); } } diff --git a/lib/pangea/models/summary_analytics_event.dart b/lib/pangea/models/summary_analytics_event.dart new file mode 100644 index 000000000..1d447a854 --- /dev/null +++ b/lib/pangea/models/summary_analytics_event.dart @@ -0,0 +1,21 @@ +import 'package:fluffychat/pangea/models/analytics_event.dart'; +import 'package:fluffychat/pangea/models/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; + } +} diff --git a/lib/pangea/models/summary_analytics_model.dart b/lib/pangea/models/summary_analytics_model.dart new file mode 100644 index 000000000..19ef857a9 --- /dev/null +++ b/lib/pangea/models/summary_analytics_model.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/models/analytics_model.dart'; +import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:flutter/foundation.dart'; + +class SummaryAnalyticsModel extends AnalyticsModel { + late List _messages; + + SummaryAnalyticsModel({ + required List messages, + super.lastUpdated, + super.prevEventId, + super.prevLastUpdated, + }) { + _messages = messages; + } + + 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(), + ModelKey.prevEventId: prevEventId, + ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(), + }; + + 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, + lastUpdated: json[_lastUpdatedKey] != null + ? DateTime.parse(json[_lastUpdatedKey]) + : null, + prevEventId: json[ModelKey.prevEventId], + prevLastUpdated: json[ModelKey.prevLastUpdated] != null + ? DateTime.parse(json[ModelKey.prevLastUpdated]) + : null, + ); + } +} diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 811f02f6f..ea86e1112 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -14,115 +17,135 @@ 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, + required this.pangeaController, + // 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 AnalyticsSelected defaultSelected; + final AnalyticsSelected selected; + + final Uri? avatar; + + final bool allowNavigateOnSelect; + final bool isSelected; + // final bool isEnabled; final bool showSpaceAnalytics; + final PangeaController pangeaController; + 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?.lastMessage?.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: widget.showSpaceAnalytics || !(room?.isSpace ?? false) + ? ListSummaryAnalytics( + chartAnalytics: tileData, + ) + : null, + selected: widget.isSelected, + onTap: () { + (room?.isSpace ?? false) && widget.allowNavigateOnSelect + ? context.go( + '/rooms/analytics/${room!.id}', + ) + : 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 55731cfb3..8e16bd8c8 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -1,12 +1,10 @@ import 'dart:async'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; -import 'package:fluffychat/pangea/extensions/client_extension.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'; import 'package:future_loading_dialog/future_loading_dialog.dart'; -import 'package:matrix/matrix.dart'; import '../../../widgets/matrix.dart'; import '../../controllers/pangea_controller.dart'; @@ -17,7 +15,6 @@ import '../../models/chart_analytics_model.dart'; class BaseAnalyticsPage extends StatefulWidget { final String pageTitle; final List tabs; - final Future Function(BuildContext) refreshData; final AnalyticsSelected defaultSelected; final AnalyticsSelected? alwaysSelected; @@ -27,7 +24,6 @@ class BaseAnalyticsPage extends StatefulWidget { super.key, required this.pageTitle, required this.tabs, - required this.refreshData, required this.alwaysSelected, required this.defaultSelected, this.myAnalyticsController, @@ -42,53 +38,57 @@ class BaseAnalyticsController extends State { 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; + @override + void initState() { + super.initState(); + setChartData(); + } - if (analyticsSelected.type == AnalyticsEntryType.privateChats) { - return pangeaController.analytics.getAnalyticsLocal( - classId: analyticsSelected.id, - chatId: AnalyticsEntryType.privateChats.toString(), - ); - } + Future onRefresh() async { + await showFutureLoadingDialog( + context: context, + future: () async { + debugPrint("updating analytics"); + await pangeaController.myAnalytics.updateAnalytics(); + await setChartData(forceUpdate: true); + refreshStream.add(true); + }, + ); + } - String? chatId = analyticsSelected.type == AnalyticsEntryType.room - ? analyticsSelected.id - : null; - chatId ??= widget.alwaysSelected?.type == AnalyticsEntryType.room - ? widget.alwaysSelected?.id - : null; + Future fetchChartData( + AnalyticsSelected? params, { + forceUpdate = false, + }) async { + ChartAnalyticsModel? data = pangeaController.analytics.getAnalyticsLocal( + timeSpan: currentTimeSpan, + defaultSelected: widget.defaultSelected, + selected: params, + forceUpdate: forceUpdate, + ); - String? studentId = analyticsSelected.type == AnalyticsEntryType.student - ? analyticsSelected.id - : null; - studentId ??= widget.alwaysSelected?.type == AnalyticsEntryType.student - ? widget.alwaysSelected?.id - : null; - - 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, + 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; @@ -103,33 +103,13 @@ class BaseAnalyticsController extends State { } 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( + await pangeaController.analytics.setConstructs( constructType: ConstructType.grammar, defaultSelected: widget.defaultSelected, selected: selected, @@ -141,54 +121,28 @@ class BaseAnalyticsController extends State { 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, ); + await setChartData(); setState(() {}); + refreshStream.add(false); } void setSelectedView(BarChartViewSelection? view) { currentLemma = null; selectedView = view; - if (!enableSelection(selected)) { - toggleSelection(selected!); - } setState(() {}); + 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 diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 0c9bf3bc4..0b877ca91 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -29,10 +29,7 @@ class BaseAnalyticsView extends StatelessWidget { switch (controller.selectedView!) { case BarChartViewSelection.messages: return MessagesBarChart( - chartAnalytics: controller.chartData( - context, - controller.selected, - ), + chartAnalytics: controller.chartData, ); case BarChartViewSelection.grammar: return ConstructList( @@ -41,6 +38,7 @@ class BaseAnalyticsView extends StatelessWidget { selected: controller.selected, controller: controller, pangeaController: controller.pangeaController, + refreshStream: controller.refreshStream, ); } } @@ -98,19 +96,34 @@ class BaseAnalyticsView extends StatelessWidget { icon: const Icon(Icons.arrow_back), onPressed: controller.navigate, ), - actions: [ - TimeSpanMenuButton( - value: controller.currentTimeSpan, - onChange: (TimeSpan value) => - controller.toggleTimeSpan(context, value), - ), - ], + // actions: [ + // TimeSpanMenuButton( + // value: controller.currentTimeSpan, + // onChange: (TimeSpan value) => + // controller.toggleTimeSpan(context, value), + // ), + // ], ), body: MaxWidthBody( withScrolling: false, child: controller.selectedView != null ? Column( children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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,28 +166,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( @@ -188,35 +191,33 @@ class BaseAnalyticsView extends StatelessWidget { .widget .tabs[0] .allowNavigateOnSelect, + pangeaController: + controller.pangeaController, ), ), 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, ), ], ), @@ -226,36 +227,25 @@ 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, ), ) .toList(), diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 877ae1788..c3051dc90 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -1,12 +1,8 @@ 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/extensions/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,14 +12,12 @@ 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}); @override @@ -31,48 +25,25 @@ class ClassAnalyticsPage extends StatefulWidget { } 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), - ); } return _classRoom; } - String className(BuildContext context) { - return classRoom?.name ?? ""; - } - @override void initState() { super.initState(); @@ -80,14 +51,6 @@ class ClassAnalyticsV2Controller extends State { 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(); }); } @@ -122,21 +85,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 +100,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..537903025 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart @@ -50,16 +50,15 @@ class ClassAnalyticsView extends StatelessWidget { ? BaseAnalyticsPage( 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..6b0790863 100644 --- a/lib/pangea/pages/analytics/class_list/class_list.dart +++ b/lib/pangea/pages/analytics/class_list/class_list.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:fluffychat/pangea/enum/time_span.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 '../../../utils/sync_status_util_v2.dart'; @@ -22,42 +22,6 @@ class AnalyticsClassList extends StatefulWidget { class AnalyticsClassListController extends State { PangeaController pangeaController = MatrixState.pangeaController; List models = []; - StreamSubscription? stateSub; - Map refreshTimer = {}; - - @override - void initState() { - super.initState(); - Future.delayed(Duration.zero, () async { - stateSub = pangeaController.matrixState.client.onRoomState.stream - .where( - (event) => event.type == PangeaEventTypes.studentAnalyticsSummary, - ) - .listen(onStateUpdate); - }); - } - - 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) { @@ -65,32 +29,32 @@ class AnalyticsClassListController extends State { 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 6a0e7f21f..5f7c4b95e 100644 --- a/lib/pangea/pages/analytics/class_list/class_list_view.dart +++ b/lib/pangea/pages/analytics/class_list/class_list_view.dart @@ -50,18 +50,23 @@ class AnalyticsClassListView extends StatelessWidget { builder: (context, snapshot) => ListView.builder( itemCount: snapshot.hasData ? snapshot.data?.length ?? 0 : 0, itemBuilder: (context, i) => AnalyticsListTile( + defaultSelected: AnalyticsSelected( + snapshot.data![i].id, + AnalyticsEntryType.space, + "", + ), 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, + selected: AnalyticsSelected( + snapshot.data![i].id, + AnalyticsEntryType.space, + snapshot.data![i].name, + ), onTap: (selected) => context.go( '/rooms/analytics/${selected.id}', ), allowNavigateOnSelect: true, - selected: false, + 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..dd5025b46 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -2,7 +2,6 @@ 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'; @@ -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, }); @@ -77,6 +78,7 @@ class ConstructListState extends State { pangeaController: widget.pangeaController, defaultSelected: widget.defaultSelected, selected: widget.selected, + refreshStream: widget.refreshStream, ), ], ); @@ -93,12 +95,12 @@ 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, @@ -106,6 +108,7 @@ class ConstructListView extends StatefulWidget { required this.controller, required this.pangeaController, required this.defaultSelected, + required this.refreshStream, this.selected, }); @@ -118,55 +121,30 @@ class ConstructListViewState extends State { final Map _msgEventCache = {}; final List _msgEvents = []; 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, - removeIT: true, - defaultSelected: widget.defaultSelected, - selected: widget.selected, - forceUpdate: true, - ); - await fetchUses(); - }, - ); + refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { + widget.pangeaController.analytics + .setConstructs( + constructType: ConstructType.grammar, + removeIT: true, + defaultSelected: widget.defaultSelected, + selected: widget.selected, + forceUpdate: true, + ) + .then((_) => setState(() {})); + }); } @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,16 +219,19 @@ 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? get constructs { + if (widget.pangeaController.analytics.constructs == null) { + return null; + } + return widget.pangeaController.myAnalytics + .aggregateConstructData(widget.pangeaController.analytics.constructs!) + .where((lemmaUses) => lemmaUses.uses.isNotEmpty) + .sorted((a, b) { + final int cmp = b.uses.length.compareTo(a.uses.length); + if (cmp != 0) return cmp; + return a.lemma.compareTo(b.lemma); + }).toList(); + } AggregateConstructUses? get currentConstruct => constructs?.firstWhereOrNull( (element) => element.lemma == widget.controller.currentLemma, @@ -302,6 +283,7 @@ class ConstructListViewState extends State { child: Center(child: CircularProgressIndicator()), ); } + if ((constructs?.isEmpty ?? true) || (widget.controller.currentLemma != null && currentConstruct == null)) { return Expanded( diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index 44843bd41..80e5c3a25 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -1,9 +1,6 @@ import 'dart:async'; import 'dart:developer'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/models/chart_analytics_model.dart'; import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,7 +8,6 @@ import 'package:matrix/matrix.dart'; import '../../../../widgets/matrix.dart'; import '../../../controllers/pangea_controller.dart'; -import '../../../extensions/client_extension.dart'; import '../../../utils/sync_status_util_v2.dart'; import '../base_analytics.dart'; import 'student_analytics_view.dart'; @@ -26,37 +22,44 @@ 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 = []; - - void onStateUpdate(Event newState) { - if (!(refreshTimer?.isActive ?? false)) { - refreshTimer = Timer( - const Duration(seconds: 3), - () => getClassAndChatAnalytics(context, true), - ); - } + @override + void initState() { + super.initState(); + 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((_) => setState(() {})); + } + return _pangeaController.myAnalytics.studentChats; + } + + List get spaces { + if (_pangeaController.myAnalytics.studentSpaces.isEmpty) { + _pangeaController.myAnalytics + .setStudentSpaces() + .then((_) => setState(() {})); + } + return _pangeaController.myAnalytics.studentSpaces; + } + + String? get userId { + final id = _pangeaController.matrixState.client.userID; + debugger(when: kDebugMode && id == null); + return id; } @override @@ -66,96 +69,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..a4ae454ca 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, @@ -47,7 +47,6 @@ class StudentAnalyticsView extends StatelessWidget { ? BaseAnalyticsPage( 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/utils/class_chat_power_levels.dart b/lib/pangea/utils/class_chat_power_levels.dart index db19bb6ee..f3b58f635 100644 --- a/lib/pangea/utils/class_chat_power_levels.dart +++ b/lib/pangea/utils/class_chat_power_levels.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; - import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import '../../widgets/matrix.dart'; import '../constants/class_default_values.dart'; import '../extensions/pangea_room_extension.dart'; @@ -16,7 +14,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 cf8904d18..125307100 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, From 04f6894b476e4ac626bf36a1e6da04857bafb228 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 3 Jun 2024 11:55:18 -0400 Subject: [PATCH 02/25] cleanup some commented out code --- .../controllers/my_analytics_controller.dart | 6 +- lib/pangea/models/constructs_model.dart | 65 ------------------- .../pages/analytics/base_analytics_view.dart | 7 -- 3 files changed, 5 insertions(+), 73 deletions(-) diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 52977f14f..da35e9fb8 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/models/constructs_model.dart'; import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; import 'package:fluffychat/pangea/models/summary_analytics_event.dart'; import 'package:fluffychat/pangea/models/summary_analytics_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; @@ -36,7 +37,10 @@ class MyAnalyticsController extends BaseController { ) async { final String? langCode = analyticsRoom.madeForLang; if (langCode == null) { - debugPrint("no lang code found for analytics room: ${analyticsRoom.id}"); + ErrorHandler.logError( + e: "no lang code found for analytics room: ${analyticsRoom.id}", + s: StackTrace.current, + ); return; } diff --git a/lib/pangea/models/constructs_model.dart b/lib/pangea/models/constructs_model.dart index 287d7512e..27165ce7d 100644 --- a/lib/pangea/models/constructs_model.dart +++ b/lib/pangea/models/constructs_model.dart @@ -190,68 +190,3 @@ extension on ConstructUseType { } } } - -// class OneConstructUse { -// String? lemma; -// ConstructType? constructType; -// String? form; -// ConstructUseType useType; -// String chatId; -// String? msgId; -// DateTime timeStamp; -// String? id; - -// OneConstructUse({ -// required this.useType, -// required this.chatId, -// required this.timeStamp, -// required this.lemma, -// required this.form, -// required this.msgId, -// required this.constructType, -// this.id, -// }); - -// factory OneConstructUse.fromJson(Map json) { -// return OneConstructUse( -// useType: ConstructUseType.values -// .firstWhere((e) => e.string == json['useType']), -// chatId: json['chatId'], -// timeStamp: DateTime.parse(json['timeStamp']), -// lemma: json['lemma'], -// form: json['form'], -// msgId: json['msgId'], -// constructType: json['constructType'] != null -// ? ConstructTypeUtil.fromString(json['constructType']) -// : null, -// id: json['id'], -// ); -// } - -// Map toJson([bool condensed = true]) { -// final Map data = { -// 'useType': useType.string, -// 'chatId': chatId, -// 'timeStamp': timeStamp.toIso8601String(), -// 'form': form, -// 'msgId': msgId, -// }; -// if (!condensed && lemma != null) data['lemma'] = lemma!; -// if (!condensed && constructType != null) { -// data['constructType'] = constructType!.string; -// } -// if (id != null) data['id'] = id; - -// return data; -// } - -// Room? getRoom(Client client) { -// return client.getRoomById(chatId); -// } - -// Future getEvent(Client client) async { -// final Room? room = getRoom(client); -// if (room == null || msgId == null) return null; -// return room.getEventById(msgId!); -// } -// } diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 0b877ca91..8709010b5 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -96,13 +96,6 @@ class BaseAnalyticsView extends StatelessWidget { 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, From 251f7c658246b2c4523b8d0dd962b6c38188f9e2 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 3 Jun 2024 12:57:46 -0400 Subject: [PATCH 03/25] updated analytics navigation to work with new constructs popup --- lib/pangea/pages/analytics/base_analytics.dart | 4 +--- lib/pangea/pages/analytics/base_analytics_view.dart | 10 ---------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 8e16bd8c8..f4b07348d 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -93,9 +93,7 @@ class BaseAnalyticsController extends State { pangeaController.analytics.currentAnalyticsTimeSpan; void navigate() { - if (currentLemma != null) { - setCurrentLemma(null); - } else if (selectedView != null) { + if (selectedView != null) { setSelectedView(null); } else { Navigator.of(context).pop(); diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 8709010b5..5c2b9fef1 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -77,16 +77,6 @@ class BaseAnalyticsView extends StatelessWidget { ? controller.setCurrentLemma(null) : null, ), - if (controller.currentLemma != null) - const TextSpan( - text: " > ", - ), - if (controller.currentLemma != null) - TextSpan( - text: controller.currentLemma, - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = () {}, - ), ], ), overflow: TextOverflow.ellipsis, From 98778abc512f912d41e4ed7dfb60bbe578b50a2d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 3 Jun 2024 13:32:12 -0400 Subject: [PATCH 04/25] reset chart data after selecting analytics tile --- lib/pangea/pages/analytics/base_analytics.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index f4b07348d..f0234e854 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -113,6 +113,7 @@ class BaseAnalyticsController extends State { selected: selected, removeIT: true, ); + await setChartData(); Future.delayed(Duration.zero, () => setState(() {})); } @@ -126,7 +127,6 @@ class BaseAnalyticsController extends State { removeIT: true, ); await setChartData(); - setState(() {}); refreshStream.add(false); } From 91f5fab0ea29a5dbe67468d44ab4092e5ae929e8 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 4 Jun 2024 10:00:04 -0400 Subject: [PATCH 05/25] fix for out-of-date cached analytics not updating --- .../message_analytics_controller.dart | 55 ++++++++++++++----- .../controllers/my_analytics_controller.dart | 19 +++++++ .../pages/analytics/base_analytics.dart | 10 +--- .../pages/analytics/base_analytics_view.dart | 12 ++-- .../pages/analytics/construct_list.dart | 15 ++--- 5 files changed, 75 insertions(+), 36 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 954bb357e..1a5741db7 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -117,6 +117,7 @@ class AnalyticsController extends BaseController { ChartAnalyticsModel? getAnalyticsLocal({ TimeSpan? timeSpan, required AnalyticsSelected defaultSelected, + required DateTime? analyticsLastUpdated, AnalyticsSelected? selected, bool forceUpdate = false, bool updateExpired = false, @@ -132,8 +133,11 @@ class AnalyticsController extends BaseController { ); if (index != -1) { + final DateTime? cachedLastUpdate = + _cachedAnalyticsModels[index].summaryLastUpdated; if ((updateExpired && _cachedAnalyticsModels[index].isExpired) || - forceUpdate) { + forceUpdate || + cachedLastUpdate != analyticsLastUpdated) { _cachedAnalyticsModels.removeAt(index); } else { return _cachedAnalyticsModels[index].chartAnalyticsModel; @@ -146,6 +150,7 @@ class AnalyticsController extends BaseController { void cacheAnalytics({ required ChartAnalyticsModel chartAnalyticsModel, required AnalyticsSelected defaultSelected, + required DateTime? summaryLastUpdated, AnalyticsSelected? selected, TimeSpan? timeSpan, }) { @@ -155,6 +160,7 @@ class AnalyticsController extends BaseController { chartAnalyticsModel: chartAnalyticsModel, defaultSelected: defaultSelected, selected: selected, + summaryLastUpdated: summaryLastUpdated, ), ); } @@ -273,10 +279,13 @@ class AnalyticsController extends BaseController { bool forceUpdate = false, }) async { try { + final DateTime? analyticsLastUpdated = await _pangeaController.myAnalytics + .analyticsLastUpdated(PangeaEventTypes.summaryAnalytics); final local = getAnalyticsLocal( defaultSelected: defaultSelected, selected: selected, forceUpdate: forceUpdate, + analyticsLastUpdated: analyticsLastUpdated, ); if (local != null && !forceUpdate) { return local; @@ -330,6 +339,7 @@ class AnalyticsController extends BaseController { defaultSelected: defaultSelected, selected: selected, timeSpan: currentAnalyticsTimeSpan, + summaryLastUpdated: analyticsLastUpdated, ); } @@ -510,26 +520,36 @@ class AnalyticsController extends BaseController { required TimeSpan timeSpan, required ConstructType constructType, required AnalyticsSelected defaultSelected, + required DateTime? constructsLastUpdated, AnalyticsSelected? selected, }) { - 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].constructsLastUpdated != + constructsLastUpdated) { + _cachedConstructs.removeAt(index); + return null; + } + return _cachedConstructs[index].events; + } + + return null; } void cacheConstructs({ required ConstructType constructType, required List events, required AnalyticsSelected defaultSelected, + required DateTime? constructsLastUpdated, AnalyticsSelected? selected, }) { _cachedConstructs.add( @@ -539,6 +559,7 @@ class AnalyticsController extends BaseController { events: events, defaultSelected: defaultSelected, selected: selected, + constructsLastUpdated: constructsLastUpdated, ), ); } @@ -638,11 +659,14 @@ class AnalyticsController extends BaseController { bool removeIT = false, bool forceUpdate = false, }) async { + final DateTime? constructsLastUpdated = await _pangeaController.myAnalytics + .analyticsLastUpdated(PangeaEventTypes.construct); final List? local = getConstructsLocal( timeSpan: currentAnalyticsTimeSpan, constructType: constructType, defaultSelected: defaultSelected, selected: selected, + constructsLastUpdated: constructsLastUpdated, ); if (local != null && !forceUpdate) { _constructs = local; @@ -691,6 +715,7 @@ class AnalyticsController extends BaseController { events: _constructs!, defaultSelected: defaultSelected, selected: selected, + constructsLastUpdated: constructsLastUpdated, ); } @@ -705,12 +730,14 @@ class ConstructCacheEntry { final List events; final AnalyticsSelected defaultSelected; AnalyticsSelected? selected; + final DateTime? constructsLastUpdated; ConstructCacheEntry({ required this.timeSpan, required this.type, required this.events, required this.defaultSelected, + required this.constructsLastUpdated, this.selected, }); } @@ -721,11 +748,13 @@ class AnalyticsCacheModel { final AnalyticsSelected defaultSelected; AnalyticsSelected? selected; late DateTime _createdAt; + final DateTime? summaryLastUpdated; AnalyticsCacheModel({ required this.timeSpan, required this.chartAnalyticsModel, required this.defaultSelected, + required this.summaryLastUpdated, this.selected, }) { _createdAt = DateTime.now(); diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 2b296009e..608596c93 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -316,6 +316,25 @@ class MyAnalyticsController extends BaseController { } return aggregatedConstructs; } + + Future analyticsLastUpdated(String type) async { + final List analyticsRooms = + _pangeaController.matrixState.client.allMyAnalyticsRooms; + if (analyticsRooms.isEmpty) return null; + final List lastUpdates = []; + for (final analyticsRoom in analyticsRooms) { + final AnalyticsEvent? lastEvent = + await analyticsRoom.getLastAnalyticsEvent( + type, + ); + if (lastEvent?.content.lastUpdated != null) { + lastUpdates.add(lastEvent!.content.lastUpdated!); + } + } + return lastUpdates.reduce( + (value, element) => value.isAfter(element) ? value : element, + ); + } } class AggregateConstructUses { diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index f0234e854..2cc2fce6c 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -65,14 +65,8 @@ class BaseAnalyticsController extends State { AnalyticsSelected? params, { forceUpdate = false, }) async { - ChartAnalyticsModel? data = pangeaController.analytics.getAnalyticsLocal( - timeSpan: currentTimeSpan, - defaultSelected: widget.defaultSelected, - selected: params, - forceUpdate: forceUpdate, - ); - - data ??= await pangeaController.analytics.getAnalytics( + final ChartAnalyticsModel data = + await pangeaController.analytics.getAnalytics( defaultSelected: widget.defaultSelected, selected: params, forceUpdate: forceUpdate, diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 5c2b9fef1..f8ca491be 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -95,11 +95,13 @@ class BaseAnalyticsView extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: controller.onRefresh, - tooltip: L10n.of(context)!.refresh, - ), + 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) => diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index dd5025b46..6b60b2ca2 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -278,14 +278,16 @@ class ConstructListViewState extends State { @override Widget build(BuildContext context) { + debugPrint( + "constructs lengths: ${constructs?.map((x) => '${x.lemma}: ${x.uses.length}').toList()}", + ); if (!widget.init || 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)), ); @@ -551,14 +553,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( From e4357a3e9ba97c84570406bf084126c05b4f300a Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 4 Jun 2024 10:15:07 -0400 Subject: [PATCH 06/25] update space list on update analytics --- lib/pangea/controllers/my_analytics_controller.dart | 2 +- .../client_extension/classes_and_exchanges_extension.dart | 8 +++++++- lib/pangea/pages/analytics/construct_list.dart | 3 --- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 608596c93..b663894c2 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -245,7 +245,6 @@ class MyAnalyticsController extends BaseController { List _studentSpaces = []; Future setStudentSpaces() async { - if (_studentSpaces.isNotEmpty) return; _studentSpaces = await _pangeaController .matrixState.client.classesAndExchangesImStudyingIn; } @@ -284,6 +283,7 @@ class MyAnalyticsController extends BaseController { Future updateAnalytics() async { await setStudentChats(); + await setStudentSpaces(); final List analyticsRooms = _pangeaController.matrixState.client.allMyAnalyticsRooms; analyticsRooms.addAll(await createMissingAnalyticsRoom()); 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/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 6b60b2ca2..2a81d8464 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -278,9 +278,6 @@ class ConstructListViewState extends State { @override Widget build(BuildContext context) { - debugPrint( - "constructs lengths: ${constructs?.map((x) => '${x.lemma}: ${x.uses.length}').toList()}", - ); if (!widget.init || fetchingUses) { return const Expanded( child: Center(child: CircularProgressIndicator()), From 9138940e351f6d85734003443ad5ad5ad134e240 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 4 Jun 2024 10:56:30 -0400 Subject: [PATCH 07/25] added first initial refresh for new analytics data structure, ensured chat and space list in my analytics is updated --- .../controllers/my_analytics_controller.dart | 1 + .../pages/analytics/base_analytics.dart | 27 +++++++++++++++++++ .../student_analytics/student_analytics.dart | 7 +++++ 3 files changed, 35 insertions(+) diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index b663894c2..e04f0a381 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -331,6 +331,7 @@ class MyAnalyticsController extends BaseController { lastUpdates.add(lastEvent!.content.lastUpdated!); } } + if (lastUpdates.isEmpty) return null; return lastUpdates.reduce( (value, element) => value.isAfter(element) ? value : element, ); diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 2cc2fce6c..680ac8a04 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -1,6 +1,10 @@ import 'dart:async'; +import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/models/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'; @@ -46,9 +50,32 @@ class BaseAnalyticsController extends State { @override void initState() { super.initState(); + runFirstRefresh(); setChartData(); } + Future runFirstRefresh() async { + final analyticsRooms = + pangeaController.matrixState.client.allMyAnalyticsRooms; + + final List analyticsEvent = []; + for (final analyticsRoom in analyticsRooms) { + final lastSummaryEvent = await analyticsRoom + .getLastAnalyticsEvent(PangeaEventTypes.summaryAnalytics); + final lastConstructEvent = + await analyticsRoom.getLastAnalyticsEvent(PangeaEventTypes.construct); + if (lastSummaryEvent != null) { + analyticsEvent.add(lastSummaryEvent); + } + if (lastConstructEvent != null) { + analyticsEvent.add(lastConstructEvent); + } + } + + if (analyticsEvent.isNotEmpty) return; + onRefresh(); + } + Future onRefresh() async { await showFutureLoadingDialog( context: context, diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index 80e5c3a25..d3d1be7d6 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -27,6 +27,13 @@ class StudentAnalyticsController extends State { @override void initState() { super.initState(); + + final listFutures = [ + _pangeaController.myAnalytics.setStudentChats(), + _pangeaController.myAnalytics.setStudentSpaces(), + ]; + Future.wait(listFutures).then((_) => setState(() {})); + stateSub = _pangeaController.myAnalytics.stateStream.listen((_) { setState(() {}); }); From 47246cd392266fa75cc0ed4007705941787fcf22 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 4 Jun 2024 12:34:01 -0400 Subject: [PATCH 08/25] updates for sub-space analytics --- .../message_analytics_controller.dart | 27 +++++++++++-------- .../pages/analytics/analytics_list_tile.dart | 12 ++++----- .../pages/analytics/base_analytics.dart | 14 +++++++++- .../pages/analytics/base_analytics_view.dart | 1 - .../class_analytics/class_analytics.dart | 4 +-- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 1a5741db7..2ea2d215a 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -97,11 +97,9 @@ class AnalyticsController extends BaseController { } } - final List spaceChildrenIds = space.spaceChildren - .map((e) => e.roomId) - .where((e) => e != null) - .cast() - .toList(); + final resp = await space.client.getSpaceHierarchy(space.id); + final List spaceChildrenIds = + resp.rooms.map((room) => room.roomId).toList(); final List allAnalyticsEvents = []; for (final analyticsEvent in analyticsEvents) { @@ -180,6 +178,14 @@ class AnalyticsController extends BaseController { 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), @@ -229,9 +235,10 @@ class AnalyticsController extends BaseController { List filtered = List.from(unfiltered); + filtered = filtered .where( - (e) => (e.content).messages.any((u) => chatIds.contains(u.chatId)), + (e) => e.content.messages.any((u) => chatIds.contains(u.chatId)), ) .toList(); @@ -429,11 +436,9 @@ class AnalyticsController extends BaseController { } } - final List spaceChildrenIds = space.spaceChildren - .map((e) => e.roomId) - .where((e) => e != null) - .cast() - .toList(); + final resp = await space.client.getSpaceHierarchy(space.id); + final List spaceChildrenIds = + resp.rooms.map((room) => room.roomId).toList(); final List allConstructs = []; for (final constructEvent in constructEvents) { diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 86cd2a6ba..9580cc135 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -25,7 +25,7 @@ class AnalyticsListTile extends StatefulWidget { required this.onTap, required this.pangeaController, // this.isEnabled = true, - this.showSpaceAnalytics = true, + // this.showSpaceAnalytics = true, this.refreshStream, }); @@ -39,7 +39,7 @@ class AnalyticsListTile extends StatefulWidget { final bool allowNavigateOnSelect; final bool isSelected; // final bool isEnabled; - final bool showSpaceAnalytics; + // final bool showSpaceAnalytics; final PangeaController pangeaController; final StreamController? refreshStream; @@ -128,11 +128,9 @@ class AnalyticsListTileState extends State { ), ], ), - subtitle: widget.showSpaceAnalytics || !(room?.isSpace ?? false) - ? ListSummaryAnalytics( - chartAnalytics: tileData, - ) - : null, + subtitle: ListSummaryAnalytics( + chartAnalytics: tileData, + ), selected: widget.isSelected, onTap: () { (room?.isSpace ?? false) && widget.allowNavigateOnSelect diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 680ac8a04..7a6e077b3 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -50,10 +50,22 @@ class BaseAnalyticsController extends State { @override void initState() { super.initState(); - runFirstRefresh(); + 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; diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index f8ca491be..1669cd534 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -163,7 +163,6 @@ class BaseAnalyticsView extends StatelessWidget { ), isSelected: controller.isSelected(item.id), - showSpaceAnalytics: false, onTap: (_) => controller.toggleSelection( AnalyticsSelected( diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 074d7ea2d..0d7402dc1 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -40,6 +40,7 @@ class ClassAnalyticsV2Controller extends State { _classRoom = classId != null ? Matrix.of(context).client.getRoomById(classId!) : null; + getChatAndStudents(); } return _classRoom; } @@ -47,6 +48,7 @@ class ClassAnalyticsV2Controller extends State { @override void initState() { super.initState(); + debugPrint("init class analytics"); Future.delayed(Duration.zero, () async { if (classRoom == null || (!(classRoom?.isSpace ?? false))) { context.go('/rooms'); @@ -62,9 +64,7 @@ class ClassAnalyticsV2Controller extends State { if (classRoom != null) { final response = await Matrix.of(context).client.getSpaceHierarchy( classRoom!.id, - maxDepth: 1, ); - students = classRoom!.students; chats = response.rooms .where( From 1fa677c8f3bda00ee70e45e7ecb67388709aafbb Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 4 Jun 2024 13:04:30 -0400 Subject: [PATCH 09/25] Updated UI of error message popup to all constraints and make it scrollable --- .../pages/analytics/construct_list.dart | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 2a81d8464..2a77abc34 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -330,38 +330,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( From 7807e618b4359f44334f8b37d65b017355b3c448 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 5 Jun 2024 09:26:48 -0400 Subject: [PATCH 10/25] enable my analytics button for users in rooms --- lib/pages/chat_list/client_chooser_button.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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: [ From 551b0cd407852aabea78dbd0a5295710dba1844d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 5 Jun 2024 10:35:16 -0400 Subject: [PATCH 11/25] removed recursive calls to setState in student analytics --- .../student_analytics/student_analytics.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index d3d1be7d6..9e51087a6 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -47,18 +47,22 @@ class StudentAnalyticsController extends State { List get chats { if (_pangeaController.myAnalytics.studentChats.isEmpty) { - _pangeaController.myAnalytics - .setStudentChats() - .then((_) => setState(() {})); + _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((_) => setState(() {})); + _pangeaController.myAnalytics.setStudentSpaces().then((_) { + if (_pangeaController.myAnalytics.studentSpaces.isNotEmpty) { + setState(() {}); + } + }); } return _pangeaController.myAnalytics.studentSpaces; } From 0e47b84552b039ebf3d8734fa19047d38ccf9e91 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 5 Jun 2024 14:25:35 -0400 Subject: [PATCH 12/25] navigation updates to make sure update happens after going to sub-space in space analytics --- lib/config/routes.dart | 53 ++++++++++++++ lib/pangea/enum/bar_chart_view_enum.dart | 12 +++- .../pages/analytics/analytics_list_tile.dart | 19 +++-- .../pages/analytics/base_analytics.dart | 31 ++++---- .../pages/analytics/base_analytics_view.dart | 70 +++++++++++-------- .../class_analytics/class_analytics.dart | 7 +- .../class_analytics/class_analytics_view.dart | 1 + .../analytics/class_list/class_list_view.dart | 8 ++- .../student_analytics/student_analytics.dart | 4 +- .../student_analytics_view.dart | 1 + 10 files changed, 151 insertions(+), 55 deletions(-) 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/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/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 9580cc135..b5a2bd770 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -1,6 +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'; @@ -24,6 +25,7 @@ class AnalyticsListTile extends StatefulWidget { required this.isSelected, required this.onTap, required this.pangeaController, + this.controller, // this.isEnabled = true, // this.showSpaceAnalytics = true, this.refreshStream, @@ -42,6 +44,7 @@ class AnalyticsListTile extends StatefulWidget { // final bool showSpaceAnalytics; final PangeaController pangeaController; + final BaseAnalyticsController? controller; final StreamController? refreshStream; @override @@ -133,11 +136,17 @@ class AnalyticsListTileState extends State { ), selected: widget.isSelected, onTap: () { - (room?.isSpace ?? false) && widget.allowNavigateOnSelect - ? context.go( - '/rooms/analytics/${room!.id}', - ) - : widget.onTap(widget.selected); + 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 && diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 7a6e077b3..7829407ed 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -19,6 +19,7 @@ import '../../models/chart_analytics_model.dart'; class BaseAnalyticsPage extends StatefulWidget { final String pageTitle; final List tabs; + final BarChartViewSelection? selectedView; final AnalyticsSelected defaultSelected; final AnalyticsSelected? alwaysSelected; @@ -30,6 +31,7 @@ class BaseAnalyticsPage extends StatefulWidget { required this.tabs, required this.alwaysSelected, required this.defaultSelected, + this.selectedView, this.myAnalyticsController, }); @@ -39,7 +41,6 @@ class BaseAnalyticsPage extends StatefulWidget { class BaseAnalyticsController extends State { final PangeaController pangeaController = MatrixState.pangeaController; - BarChartViewSelection? selectedView; AnalyticsSelected? selected; String? currentLemma; ChartAnalyticsModel? chartData; @@ -125,14 +126,6 @@ class BaseAnalyticsController extends State { TimeSpan get currentTimeSpan => pangeaController.analytics.currentAnalyticsTimeSpan; - void navigate() { - if (selectedView != null) { - setSelectedView(null); - } else { - Navigator.of(context).pop(); - } - } - Future toggleSelection(AnalyticsSelected selectedParam) async { setState(() { debugPrint("selectedParam.id is ${selectedParam.id}"); @@ -163,13 +156,6 @@ class BaseAnalyticsController extends State { refreshStream.add(false); } - void setSelectedView(BarChartViewSelection? view) { - currentLemma = null; - selectedView = view; - setState(() {}); - refreshStream.add(false); - } - void setCurrentLemma(String? lemma) { currentLemma = lemma; setState(() {}); @@ -206,6 +192,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 1669cd534..151497063 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,11 +23,11 @@ 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, @@ -60,36 +61,32 @@ 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 = () { + 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.selectedView != null) + if (controller.widget.selectedView != null) const TextSpan( text: " > ", ), - if (controller.selectedView != null) - TextSpan( - style: const TextStyle(decoration: TextDecoration.underline), - text: controller.selectedView!.string(context), - recognizer: TapGestureRecognizer() - ..onTap = () => controller.currentLemma != null - ? controller.setCurrentLemma(null) - : null, - ), + if (controller.widget.selectedView != null) + TextSpan(text: controller.widget.selectedView!.string(context)), ], ), overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: controller.navigate, - ), ), body: MaxWidthBody( withScrolling: false, - child: controller.selectedView != null + child: controller.widget.selectedView != null ? Column( children: [ Row( @@ -177,6 +174,7 @@ class BaseAnalyticsView extends StatelessWidget { .allowNavigateOnSelect, pangeaController: controller.pangeaController, + controller: controller, ), ), if (controller @@ -202,6 +200,7 @@ class BaseAnalyticsView extends StatelessWidget { onTap: controller.toggleSelection, pangeaController: controller.pangeaController, + controller: controller, ), ], ), @@ -230,6 +229,7 @@ class BaseAnalyticsView extends StatelessWidget { .allowNavigateOnSelect, pangeaController: controller.pangeaController, + controller: controller, ), ) .toList(), @@ -249,7 +249,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, @@ -258,13 +258,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, @@ -273,9 +280,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 0d7402dc1..f271ba96d 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:developer'; import 'package:fluffychat/pangea/constants/pangea_room_types.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/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart'; @@ -18,7 +19,8 @@ import 'class_analytics_view.dart'; enum AnalyticsPageType { classList, student, classDetails } class ClassAnalyticsPage extends StatefulWidget { - const ClassAnalyticsPage({super.key}); + final BarChartViewSelection? selectedView; + const ClassAnalyticsPage({super.key, this.selectedView}); @override State createState() => ClassAnalyticsV2Controller(); @@ -40,6 +42,9 @@ class ClassAnalyticsV2Controller extends State { _classRoom = classId != null ? Matrix.of(context).client.getRoomById(classId!) : null; + if (_classRoom == null) { + context.go('/rooms/analytics'); + } getChatAndStudents(); } return _classRoom; 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 537903025..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,6 +48,7 @@ class ClassAnalyticsView extends StatelessWidget { return controller.classId != null ? BaseAnalyticsPage( + selectedView: controller.widget.selectedView, pageTitle: pageTitle, tabs: [tab1, tab2], alwaysSelected: AnalyticsSelected( 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 8d4bc6a5a..d8cafcf8e 100644 --- a/lib/pangea/pages/analytics/class_list/class_list_view.dart +++ b/lib/pangea/pages/analytics/class_list/class_list_view.dart @@ -61,9 +61,11 @@ class AnalyticsClassListView extends StatelessWidget { AnalyticsEntryType.space, snapshot.data![i].name, ), - onTap: (selected) => context.go( - '/rooms/analytics/${selected.id}', - ), + onTap: (selected) { + context.go( + '/rooms/analytics/${selected.id}', + ); + }, allowNavigateOnSelect: true, isSelected: false, pangeaController: controller.pangeaController, diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index 9e51087a6..65bd533e8 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +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'; @@ -13,7 +14,8 @@ 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(); 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 a4ae454ca..5b8924581 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart @@ -45,6 +45,7 @@ class StudentAnalyticsView extends StatelessWidget { return controller.userId != null ? BaseAnalyticsPage( + selectedView: controller.widget.selectedView, pageTitle: pageTitle, tabs: [chatTabData, classTabData], alwaysSelected: AnalyticsSelected( From 20cdc3796a02b5c7877e198aa2c9ac39d6d5841d Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 7 Jun 2024 13:58:37 -0400 Subject: [PATCH 13/25] updates to how analytics events are processed, stored, and displayed. Added automatic updating of student analytics events. --- lib/pages/chat_list/chat_list.dart | 1 + lib/pangea/constants/local.key.dart | 1 + .../message_analytics_controller.dart | 420 ++++++++++++---- .../controllers/my_analytics_controller.dart | 476 ++++++++---------- lib/pangea/enum/time_span.dart | 3 +- .../events_extension.dart | 107 ++-- .../pangea_room_extension.dart | 21 +- .../room_analytics_extension.dart | 88 ++-- .../models/analytics/analytics_event.dart | 59 +++ .../models/analytics/analytics_model.dart | 19 + .../chart_analytics_model.dart | 6 +- .../models/analytics/constructs_event.dart | 65 +++ .../constructs_model.dart} | 126 +++-- .../analytics/summary_analytics_event.dart | 35 ++ .../summary_analytics_model.dart} | 115 +++-- lib/pangea/models/analytics_event.dart | 28 -- lib/pangea/models/analytics_model.dart | 11 - lib/pangea/models/analytics_model_old.dart | 100 ---- lib/pangea/models/analytics_model_older.dart | 124 ----- lib/pangea/models/analytics_model_oldest.dart | 77 --- lib/pangea/models/choreo_record.dart | 2 +- lib/pangea/models/class_analytics_model.dart | 100 ---- lib/pangea/models/constructs_event.dart | 52 -- lib/pangea/models/constructs_model.dart | 191 ------- lib/pangea/models/headwords.dart | 2 +- .../models/student_analytics_event.dart | 165 ------ .../models/student_analytics_event_old.dart | 51 -- .../models/summary_analytics_event.dart | 21 - .../models/summary_analytics_model.dart | 57 --- .../pages/analytics/analytics_list_tile.dart | 2 +- .../pages/analytics/base_analytics.dart | 16 +- .../analytics/class_list/class_list.dart | 2 +- .../pages/analytics/construct_list.dart | 5 +- .../analytics/list_summary_analytics.dart | 3 +- .../pages/analytics/messages_bar_chart.dart | 6 +- 35 files changed, 989 insertions(+), 1568 deletions(-) create mode 100644 lib/pangea/models/analytics/analytics_event.dart create mode 100644 lib/pangea/models/analytics/analytics_model.dart rename lib/pangea/models/{ => analytics}/chart_analytics_model.dart (96%) create mode 100644 lib/pangea/models/analytics/constructs_event.dart rename lib/pangea/models/{constructs_analytics_model.dart => analytics/constructs_model.dart} (67%) create mode 100644 lib/pangea/models/analytics/summary_analytics_event.dart rename lib/pangea/models/{student_analytics_summary_model.dart => analytics/summary_analytics_model.dart} (72%) delete mode 100644 lib/pangea/models/analytics_event.dart delete mode 100644 lib/pangea/models/analytics_model.dart delete mode 100644 lib/pangea/models/analytics_model_old.dart delete mode 100644 lib/pangea/models/analytics_model_older.dart delete mode 100644 lib/pangea/models/analytics_model_oldest.dart delete mode 100644 lib/pangea/models/class_analytics_model.dart delete mode 100644 lib/pangea/models/constructs_event.dart delete mode 100644 lib/pangea/models/constructs_model.dart delete mode 100644 lib/pangea/models/student_analytics_event.dart delete mode 100644 lib/pangea/models/student_analytics_event_old.dart delete mode 100644 lib/pangea/models/summary_analytics_event.dart delete mode 100644 lib/pangea/models/summary_analytics_model.dart diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 7d1370295..5d6286404 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -845,6 +845,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/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/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 2ea2d215a..43b119087 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:collection/collection.dart'; @@ -5,20 +6,24 @@ 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/constructs_event.dart'; -import 'package:fluffychat/pangea/models/summary_analytics_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/constructs_model.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 '../models/chart_analytics_model.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; @@ -29,6 +34,7 @@ class AnalyticsController extends BaseController { _pangeaController = pangeaController; } + ///////// TIME SPANS ////////// String get _analyticsTimeSpanKey => "ANALYTICS_TIME_SPAN_KEY"; TimeSpan get currentAnalyticsTimeSpan { @@ -57,50 +63,137 @@ class AnalyticsController extends BaseController { ); } - Future> allMySummaryAnalytics() async { + 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, + ), + ); + } + + 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; + } + + //////////////////////////// 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( + final List? roomEvents = + await analyticsRoom.getAnalyticsEvents( type: PangeaEventTypes.summaryAnalytics, since: currentAnalyticsTimeSpan.cutOffDate, - )) - ?.cast(); - allEvents.addAll(roomEvents ?? []); + userId: _pangeaController.matrixState.client.userID!, + ); + + allEvents.addAll( + roomEvents?.cast() ?? [], + ); } return allEvents; } - Future> allSpaceMemberAnalytics( + 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, ); - final List analyticsEvents = []; + + // 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( + final List? roomEvents = + await analyticsRoom.getAnalyticsEvents( type: PangeaEventTypes.summaryAnalytics, since: currentAnalyticsTimeSpan.cutOffDate, - )) - ?.cast(); - analyticsEvents.addAll(roomEvents ?? []); + userId: student.id, + ); + analyticsEvents.addAll( + roomEvents?.cast() ?? [], + ); } } + // get a list of all the space's children, including sub-space children final resp = await space.client.getSpaceHierarchy(space.id); final List spaceChildrenIds = resp.rooms.map((room) => room.roomId).toList(); + // 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( @@ -115,10 +208,10 @@ class AnalyticsController extends BaseController { ChartAnalyticsModel? getAnalyticsLocal({ TimeSpan? timeSpan, required AnalyticsSelected defaultSelected, - required DateTime? analyticsLastUpdated, AnalyticsSelected? selected, bool forceUpdate = false, bool updateExpired = false, + DateTime? lastUpdated, }) { timeSpan ??= currentAnalyticsTimeSpan; final int index = _cachedAnalyticsModels.indexWhere( @@ -131,11 +224,9 @@ class AnalyticsController extends BaseController { ); if (index != -1) { - final DateTime? cachedLastUpdate = - _cachedAnalyticsModels[index].summaryLastUpdated; if ((updateExpired && _cachedAnalyticsModels[index].isExpired) || forceUpdate || - cachedLastUpdate != analyticsLastUpdated) { + _cachedAnalyticsModels[index].needsUpdate(lastUpdated)) { _cachedAnalyticsModels.removeAt(index); } else { return _cachedAnalyticsModels[index].chartAnalyticsModel; @@ -148,7 +239,6 @@ class AnalyticsController extends BaseController { void cacheAnalytics({ required ChartAnalyticsModel chartAnalyticsModel, required AnalyticsSelected defaultSelected, - required DateTime? summaryLastUpdated, AnalyticsSelected? selected, TimeSpan? timeSpan, }) { @@ -158,7 +248,6 @@ class AnalyticsController extends BaseController { chartAnalyticsModel: chartAnalyticsModel, defaultSelected: defaultSelected, selected: selected, - summaryLastUpdated: summaryLastUpdated, ), ); } @@ -256,6 +345,14 @@ class AnalyticsController extends BaseController { 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; @@ -286,20 +383,11 @@ class AnalyticsController extends BaseController { bool forceUpdate = false, }) async { try { - final DateTime? analyticsLastUpdated = await _pangeaController.myAnalytics - .analyticsLastUpdated(PangeaEventTypes.summaryAnalytics); - final local = getAnalyticsLocal( - defaultSelected: defaultSelected, - selected: selected, - forceUpdate: forceUpdate, - analyticsLastUpdated: analyticsLastUpdated, - ); - if (local != null && !forceUpdate) { - return local; - } - 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, @@ -317,13 +405,60 @@ class AnalyticsController extends BaseController { 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 allSpaceMemberAnalytics(space!) - : await allMySummaryAnalytics(); + ? await spaceMemberAnalytics(space!) + : await mySummaryAnalytics(); + // filter out the analytics events based on filters the user has chosen final List filteredAnalytics = await filterAnalytics( unfilteredAnalytics: summaryEvents, @@ -332,6 +467,7 @@ class AnalyticsController extends BaseController { selected: selected, ); + // then create and return the model to be displayed final ChartAnalyticsModel newModel = ChartAnalyticsModel( timeSpan: currentAnalyticsTimeSpan, msgs: filteredAnalytics @@ -340,15 +476,12 @@ class AnalyticsController extends BaseController { .toList(), ); - if (local == null) { - cacheAnalytics( - chartAnalyticsModel: newModel, - defaultSelected: defaultSelected, - selected: selected, - timeSpan: currentAnalyticsTimeSpan, - summaryLastUpdated: analyticsLastUpdated, - ); - } + cacheAnalytics( + chartAnalyticsModel: newModel, + defaultSelected: defaultSelected, + selected: selected, + timeSpan: currentAnalyticsTimeSpan, + ); return newModel; } catch (err, s) { @@ -379,6 +512,8 @@ class AnalyticsController extends BaseController { final List? roomEvents = (await analyticsRoom.getAnalyticsEvents( type: PangeaEventTypes.construct, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: _pangeaController.matrixState.client.userID!, )) ?.cast(); allConstructs.addAll(roomEvents ?? []); @@ -430,6 +565,8 @@ class AnalyticsController extends BaseController { final List? roomEvents = (await analyticsRoom.getAnalyticsEvents( type: PangeaEventTypes.construct, + since: currentAnalyticsTimeSpan.cutOffDate, + userId: student.id, )) ?.cast(); constructEvents.addAll(roomEvents ?? []); @@ -525,8 +662,8 @@ class AnalyticsController extends BaseController { required TimeSpan timeSpan, required ConstructType constructType, required AnalyticsSelected defaultSelected, - required DateTime? constructsLastUpdated, AnalyticsSelected? selected, + DateTime? lastUpdated, }) { final index = _cachedConstructs.indexWhere( (e) => @@ -539,8 +676,7 @@ class AnalyticsController extends BaseController { ); if (index > -1) { - if (_cachedConstructs[index].constructsLastUpdated != - constructsLastUpdated) { + if (_cachedConstructs[index].needsUpdate(lastUpdated)) { _cachedConstructs.removeAt(index); return null; } @@ -554,19 +690,16 @@ class AnalyticsController extends BaseController { required ConstructType constructType, required List events, required AnalyticsSelected defaultSelected, - required DateTime? constructsLastUpdated, AnalyticsSelected? selected, }) { - _cachedConstructs.add( - ConstructCacheEntry( - timeSpan: currentAnalyticsTimeSpan, - type: constructType, - events: events, - defaultSelected: defaultSelected, - selected: selected, - constructsLastUpdated: constructsLastUpdated, - ), + final entry = ConstructCacheEntry( + timeSpan: currentAnalyticsTimeSpan, + type: constructType, + events: List.from(events), + defaultSelected: defaultSelected, + selected: selected, ); + _cachedConstructs.add(entry); } Future> getMyConstructs({ @@ -661,31 +794,75 @@ class AnalyticsController extends BaseController { required ConstructType constructType, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, - bool removeIT = false, + bool removeIT = true, bool forceUpdate = false, }) async { - final DateTime? constructsLastUpdated = await _pangeaController.myAnalytics - .analyticsLastUpdated(PangeaEventTypes.construct); + 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, + }, + ); + settingConstructs = false; + return _constructs; + } + await space.postLoad(); + langCode = _pangeaController.languageController.activeL2Code( + roomID: space.id, + ); + if (langCode == null) { + ErrorHandler.logError( + m: "langCode missing in setConstructs", + data: { + "space": space, + }, + ); + settingConstructs = false; + return _constructs; + } + } + + 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, + ); + } 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, - constructsLastUpdated: constructsLastUpdated, + lastUpdated: lastUpdated, ); if (local != null && !forceUpdate) { _constructs = local; - return _constructs; - } - - if (settingConstructs) return _constructs; - settingConstructs = true; - await _pangeaController.matrixState.client.roomsLoading; - Room? space; - if (defaultSelected.type == AnalyticsEntryType.space) { - space = _pangeaController.matrixState.client.getRoomById( - defaultSelected.id, - ); + settingConstructs = false; + return local; } final filteredConstructs = space == null @@ -720,46 +897,48 @@ class AnalyticsController extends BaseController { events: _constructs!, defaultSelected: defaultSelected, selected: selected, - constructsLastUpdated: constructsLastUpdated, ); } settingConstructs = false; return _constructs; } + + // used to aggregate ConstructEvents from + // multiple senders (students) with the same lemma + List aggregateConstructData( + List constructs, + ) { + final Map> lemmasToConstructs = {}; + for (final construct in constructs) { + for (final lemmaUses in construct.content.uses) { + lemmasToConstructs[lemmaUses.lemma] ??= []; + lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses); + } + } + + final List aggregatedConstructs = []; + for (final lemmaToConstructs in lemmasToConstructs.entries) { + final List lemmaConstructs = + lemmaToConstructs.value; + final AggregateConstructUses aggregatedData = AggregateConstructUses( + lemmaUses: lemmaConstructs, + ); + aggregatedConstructs.add(aggregatedData); + } + return aggregatedConstructs; + } } -class ConstructCacheEntry { +abstract class CacheEntry { final TimeSpan timeSpan; - final ConstructType type; - final List events; final AnalyticsSelected defaultSelected; AnalyticsSelected? selected; - final DateTime? constructsLastUpdated; + late final DateTime _createdAt; - ConstructCacheEntry({ + CacheEntry({ required this.timeSpan, - required this.type, - required this.events, required this.defaultSelected, - required this.constructsLastUpdated, - this.selected, - }); -} - -class AnalyticsCacheModel { - TimeSpan timeSpan; - ChartAnalyticsModel chartAnalyticsModel; - final AnalyticsSelected defaultSelected; - AnalyticsSelected? selected; - late DateTime _createdAt; - final DateTime? summaryLastUpdated; - - AnalyticsCacheModel({ - required this.timeSpan, - required this.chartAnalyticsModel, - required this.defaultSelected, - required this.summaryLastUpdated, this.selected, }) { _createdAt = DateTime.now(); @@ -768,4 +947,47 @@ class AnalyticsCacheModel { 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 ConstructCacheEntry extends CacheEntry { + final ConstructType type; + final List events; + + 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 e04f0a381..c9c9def40 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -1,40 +1,188 @@ 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/pangea_message_event.dart'; -import 'package:fluffychat/pangea/models/analytics_event.dart'; -import 'package:fluffychat/pangea/models/constructs_event.dart'; -import 'package:fluffychat/pangea/models/constructs_model.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; -import 'package:fluffychat/pangea/models/summary_analytics_event.dart'; -import 'package:fluffychat/pangea/models/summary_analytics_model.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'; +// 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; } - final List analyticsEventTypes = [ - PangeaEventTypes.summaryAnalytics, - PangeaEventTypes.construct, - ]; + // 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; - Future sendAllAnalyticsEvents( + // 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) ?? false) { + debugPrint("analytics out-of-date, updating"); + await updateAnalytics(); + lastUpdated = await _pangeaController.analytics + .myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics); + } + + client.onSync.stream + .where((SyncUpdate update) => update.rooms?.join != null) + .listen((update) { + updateAnalyticsTimer(update, lastUpdated); + }); + } + + // 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) { + 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 event.originServerTs.isAfter(lastUpdated ?? DateTime.now()) && + 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( @@ -44,175 +192,78 @@ class MyAnalyticsController extends BaseController { return; } - final Map prevEvents = {}; - for (final type in analyticsEventTypes) { - final prevEvent = await analyticsRoom.getLastAnalyticsEvent(type); - prevEvents[type] = prevEvent; - } + // 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!, + ); - final List lastUpdates = prevEvents.values - .map((e) => e?.content.lastUpdated) - .cast() - .toList(); - - DateTime? earliestLastUpdated; - if (!lastUpdates.any((updated) => updated == null)) { - earliestLastUpdated = lastUpdates.reduce( - (min, e) => e!.isBefore(min!) ? e : min, - ); - } - - final List analyticsContent = []; - final List constructsContent = []; + // 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; - final List recentMsgs = - await chat.myMessageEventsInChat( - since: earliestLastUpdated, - ); - - analyticsContent.addAll( - formatAnalyticsContent( - recentMsgs, - prevEvents[PangeaEventTypes.summaryAnalytics] - as SummaryAnalyticsEvent?, - ), - ); - - constructsContent.addAll( - formatConstructsContent( - recentMsgs, - prevEvents[PangeaEventTypes.construct] as ConstructAnalyticsEvent?, - ), - ); - } - - if (analyticsContent.isNotEmpty) { - final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel( - messages: analyticsContent, - lastUpdated: DateTime.now(), - prevEventId: - prevEvents[PangeaEventTypes.summaryAnalytics]?.event.eventId, - prevLastUpdated: - prevEvents[PangeaEventTypes.summaryAnalytics]?.content.lastUpdated, - ); - await analyticsRoom.sendEvent( - analyticsModel.toJson(), - type: PangeaEventTypes.summaryAnalytics, - ); - } - - if (constructsContent.isNotEmpty) { - final Map> lemmasUses = {}; - for (final use in constructsContent) { - if (use.lemma == null) { - debugPrint("use has no lemma!"); - continue; - } - lemmasUses[use.lemma!] ??= []; - lemmasUses[use.lemma]!.add(use); + // 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; } - final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel( - type: ConstructType.grammar, - uses: lemmasUses.entries - .map( - (entry) => LemmaConstructsModel( - lemma: entry.key, - uses: entry.value, - ), - ) - .toList(), - lastUpdated: DateTime.now(), - prevEventId: prevEvents[PangeaEventTypes.construct]?.event.eventId, - prevLastUpdated: - prevEvents[PangeaEventTypes.construct]?.content.lastUpdated, - ); + if (lastUpdated != null) { + recentMsgs.removeWhere( + (msg) => msg.event.originServerTs.isBefore(lastUpdated), + ); + } - await analyticsRoom.sendEvent( - constructsModel.toJson(), - type: PangeaEventTypes.construct, + // 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, + ); } - List formatAnalyticsContent( - List recentMsgs, - SummaryAnalyticsEvent? prevEvent, - ) { - List filtered = List.from(recentMsgs); - if (prevEvent?.content.lastUpdated != null) { - filtered = recentMsgs - .where( - (msg) => msg.event.originServerTs.isAfter( - prevEvent!.content.lastUpdated!, - ), - ) - .toList(); - } - - final List addedMsgIds = - prevEvent?.content.messages.map((msg) => msg.eventId).toList() ?? []; - - filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId)); - - final List records = filtered + // 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( - (msg) => RecentMessageRecord( - eventId: msg.eventId, - chatId: msg.room.id, - useType: msg.useType, - time: msg.originServerTs, + (space) => _pangeaController.languageController.activeL2Code( + roomID: space.id, ), ) .toList(); - - return records; - } - - List formatConstructsContent( - List recentMsgs, - ConstructAnalyticsEvent? prevEvent, - ) { - List filtered = List.from(recentMsgs); - if (prevEvent?.content.lastUpdated != null) { - filtered = recentMsgs - .where( - (msg) => msg.event.originServerTs.isAfter( - prevEvent!.content.lastUpdated!, - ), - ) - .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); } - - final List addedMsgIds = prevEvent?.content.uses - .map((lemmause) => lemmause.uses.map((use) => use.msgId)) - .expand((element) => element) - .where((element) => element != null) - .cast() - .toList() ?? - []; - - filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId)); - - final List uses = filtered - .map( - (msg) => msg.originalSent?.choreo?.toGrammarConstructUse( - msg.eventId, - msg.room.id, - msg.originServerTs, - ), - ) - .where((element) => element != null) - .cast>() - .expand((element) => element) - .toList(); - - return uses; + return _pangeaController.matrixState.client.allMyAnalyticsRooms; } List _studentChats = []; @@ -259,103 +310,4 @@ class MyAnalyticsController extends BaseController { return []; } } - - // 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> createMissingAnalyticsRoom() 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; - } - - Future updateAnalytics() async { - await setStudentChats(); - await setStudentSpaces(); - final List analyticsRooms = - _pangeaController.matrixState.client.allMyAnalyticsRooms; - analyticsRooms.addAll(await createMissingAnalyticsRoom()); - - for (final Room analyticsRoom in analyticsRooms) { - await sendAllAnalyticsEvents(analyticsRoom); - } - } - - // used to aggregate ConstructEvents, from multiple senders (students) with the same lemma - List aggregateConstructData( - List constructs, - ) { - final Map> lemmasToConstructs = {}; - for (final construct in constructs) { - for (final lemmaUses in construct.content.uses) { - lemmasToConstructs[lemmaUses.lemma] ??= []; - lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses); - } - } - - final List aggregatedConstructs = []; - for (final lemmaToConstructs in lemmasToConstructs.entries) { - final List lemmaConstructs = - lemmaToConstructs.value; - final AggregateConstructUses aggregatedData = AggregateConstructUses( - lemmaUses: lemmaConstructs, - ); - aggregatedConstructs.add(aggregatedData); - } - return aggregatedConstructs; - } - - Future analyticsLastUpdated(String type) async { - final List analyticsRooms = - _pangeaController.matrixState.client.allMyAnalyticsRooms; - if (analyticsRooms.isEmpty) return null; - final List lastUpdates = []; - for (final analyticsRoom in analyticsRooms) { - final AnalyticsEvent? lastEvent = - await analyticsRoom.getLastAnalyticsEvent( - type, - ); - if (lastEvent?.content.lastUpdated != null) { - lastUpdates.add(lastEvent!.content.lastUpdated!); - } - } - if (lastUpdates.isEmpty) return null; - return lastUpdates.reduce( - (value, element) => value.isAfter(element) ? value : element, - ); - } -} - -class AggregateConstructUses { - final List _lemmaUses; - - AggregateConstructUses({required List lemmaUses}) - : _lemmaUses = lemmaUses; - - String get lemma { - assert( - _lemmaUses.isNotEmpty && - _lemmaUses.every( - (construct) => construct.lemma == _lemmaUses.first.lemma, - ), - ); - return _lemmaUses.first.lemma; - } - - List get uses => _lemmaUses - .map((lemmaUse) => lemmaUse.uses) - .expand((element) => element) - .toList(); } 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/pangea_room_extension/events_extension.dart b/lib/pangea/extensions/pangea_room_extension/events_extension.dart index b91e8afe1..feb71aa4a 100644 --- a/lib/pangea/extensions/pangea_room_extension/events_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/events_extension.dart @@ -7,7 +7,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); @@ -268,58 +267,82 @@ extension EventsRoomExtension on Room { Future> myMessageEventsInChat({ DateTime? since, + }) async { + 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, + ); + }).toList(); + } + + // 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 { int numberOfSearches = 0; - if (isSpace) { - throw Exception( - "In messageListForChat with room that is not a chat", - ); - } final Timeline timeline = await getTimeline(); - while (timeline.canRequestHistory && numberOfSearches < 50) { - try { - await timeline.requestHistory(); - } catch (err) { - break; + + 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 (timeline.events.any( - (event) => event.originServerTs.isAfter(since ?? DateTime.now()), - )) { + if (reachedEnd()) { break; } } - final List msgs = []; - for (Event event in timeline.events) { - final bool hasAnalytics = (event.senderId == client.userID) && - (event.type == EventTypes.Message) && - (event.content['msgtype'] == MessageTypes.Text && - !(event.relationshipType == RelationshipTypes.edit)); - if (hasAnalytics && - (since == null || event.originServerTs.isAfter(since))) { - if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) { - event = event - .aggregatedEvents( - timeline, - RelationshipTypes.edit, - ) - .sorted( - (a, b) => b.originServerTs.compareTo(a.originServerTs), - ) - .firstOrNull ?? - event; - } - final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: true, - ); - msgs.add(pMsgEvent); - } + 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 msgs; + + 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); 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 4b68992d3..166ab179c 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -6,11 +6,12 @@ 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_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/constructs_event.dart'; -import 'package:fluffychat/pangea/models/summary_analytics_event.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -28,7 +29,6 @@ import '../../constants/pangea_event_types.dart'; import '../../enum/use_type.dart'; import '../../models/choreo_record.dart'; import '../../models/representation_content_model.dart'; -import '../../models/student_analytics_summary_model.dart'; import '../client_extension/client_extension.dart'; part "children_and_parents_extension.dart"; @@ -69,19 +69,20 @@ extension PangeaRoom on Room { Future getLastAnalyticsEvent( String type, + String userId, ) async => - await _getLastAnalyticsEvent(type); + await _getLastAnalyticsEvent(type, userId); - Future getPrevAnalyticsEvent( - AnalyticsEvent analyticsEvent, - ) async => - await _getPrevAnalyticsEvent(analyticsEvent); + 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); + await _getAnalyticsEvents(type: type, since: since, userId: userId); String? get madeForLang => _madeForLang; 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 3fa2bc247..756f83adf 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -197,77 +197,53 @@ extension AnalyticsRoomExtension on Room { Future _getLastAnalyticsEvent( String type, + String userId, ) async { - final Timeline timeline = await getTimeline(); - int requests = 0; - Event? lastEvent = timeline.events.firstWhereOrNull( - (event) => event.type == type, + final List events = await getEventsBySender( + type: type, + sender: userId, + count: 1, ); - - while (requests < 10 && timeline.canRequestHistory && lastEvent == null) { - await timeline.requestHistory(); - lastEvent = timeline.events.firstWhereOrNull( - (event) => event.type == type, - ); - requests++; - } - - if (lastEvent == null) return null; - + if (events.isEmpty) return null; + final Event event = events.first; + AnalyticsEvent? analyticsEvent; switch (type) { case PangeaEventTypes.summaryAnalytics: - return SummaryAnalyticsEvent(event: lastEvent); + analyticsEvent = SummaryAnalyticsEvent(event: event); case PangeaEventTypes.construct: - return ConstructAnalyticsEvent(event: lastEvent); + analyticsEvent = ConstructAnalyticsEvent(event: event); } - - return null; + return analyticsEvent; } - Future _getPrevAnalyticsEvent( - AnalyticsEvent analyticsEvent, - ) async { - if (analyticsEvent.content.prevEventId == null) { - return null; - } - final Event? prevEvent = await getEventById( - analyticsEvent.content.prevEventId!, - ); - if (prevEvent == null) return null; - - switch (analyticsEvent.event.type) { - case PangeaEventTypes.summaryAnalytics: - return SummaryAnalyticsEvent(event: prevEvent); - case PangeaEventTypes.construct: - return ConstructAnalyticsEvent(event: prevEvent); - } - - return null; + 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 AnalyticsEvent? mostRecentEvent = await getLastAnalyticsEvent(type); - if (mostRecentEvent == null) return null; - final List events = [mostRecentEvent]; - - bool getAllEvents() => - since == null && events.last.content.prevEventId == null; - - bool reachedUpdated() => - since != null && - (events.last.content.lastUpdated?.isBefore(since) ?? true); - - while (getAllEvents() || !reachedUpdated()) { - final AnalyticsEvent? prevEvent = await getPrevAnalyticsEvent( - events.last, - ); - if (prevEvent == null) break; - events.add(prevEvent); + 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 events; + + return analyticsEvents; } String? get _madeForLang { 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 96% rename from lib/pangea/models/chart_analytics_model.dart rename to lib/pangea/models/analytics/chart_analytics_model.dart index af06a0958..651ec1152 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; diff --git a/lib/pangea/models/analytics/constructs_event.dart b/lib/pangea/models/analytics/constructs_event.dart new file mode 100644 index 000000000..d297ba893 --- /dev/null +++ b/lib/pangea/models/analytics/constructs_event.dart @@ -0,0 +1,65 @@ +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/utils/error_handler.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 { + // create a map of lemmas to their uses + final Map> lemmasToUses = {}; + for (final use in uses) { + if (use.lemma == null) { + ErrorHandler.logError( + e: "use has no lemma in sendConstructsEvent", + s: StackTrace.current, + ); + continue; + } + lemmasToUses[use.lemma!] ??= []; + lemmasToUses[use.lemma]!.add(use); + } + + // convert the map of lemmas to uses into a list of LemmaConstructsModel + // each entry in this list contains one lemma to many uses + final List lemmaUses = lemmasToUses.entries + .map( + (entry) => LemmaConstructsModel( + lemma: entry.key, + uses: entry.value, + ), + ) + .toList(); + + // finally, send the construct analytics event to the analytics room + final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel( + type: ConstructType.grammar, + uses: lemmaUses, + ); + + 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 67% rename from lib/pangea/models/constructs_analytics_model.dart rename to lib/pangea/models/analytics/constructs_model.dart index b45da25e6..a16855c2b 100644 --- a/lib/pangea/models/constructs_analytics_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -1,70 +1,94 @@ -import 'dart:developer'; - import 'package:fluffychat/pangea/constants/model_keys.dart'; -import 'package:flutter/foundation.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_model.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import '../enum/construct_type_enum.dart'; +import '../../enum/construct_type_enum.dart'; -class ConstructUses { - String lemma; +class ConstructAnalyticsModel extends AnalyticsModel { ConstructType type; + List uses; - 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, + ConstructAnalyticsModel({ required this.type, 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) { + return ConstructAnalyticsModel( + type: ConstructTypeUtil.fromString(json['type']), + uses: json[_usesKey] + .values + .map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses)) + .cast() + .toList(), ); - return ConstructUses( + } + + toJson() { + final Map usesMap = {}; + for (final use in uses) { + usesMap[use.lemma] = use.toJson(); + } + + return { + 'type': type.string, + _usesKey: usesMap, + }; + } + + static List formatConstructsContent( + List recentMsgs, + ) { + final List filtered = List.from(recentMsgs); + final List uses = filtered + .map( + (msg) => msg.originalSent?.choreo?.toGrammarConstructUse( + msg.eventId, + msg.room.id, + msg.originServerTs, + ), + ) + .where((element) => element != null) + .cast>() + .expand((element) => element) + .toList(); + + return uses; + } +} + +class LemmaConstructsModel { + String lemma; + List uses; + + LemmaConstructsModel({ + required this.lemma, + this.uses = const [], + }); + + factory LemmaConstructsModel.fromJson(Map json) { + return LemmaConstructsModel( lemma: json[ModelKey.lemma], - uses: (json['uses'] as Iterable) + 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() { + Map toJson() { return { ModelKey.lemma: lemma, 'uses': uses.map((use) => use.toJson()).toList(), - 'type': type.string, }; } - - void addUsesByUseType(List uses) { - for (final use in uses) { - if (use.lemma != lemma) { - throw Exception('lemma mismatch'); - } - uses.add(use); - } - } } enum ConstructUseType { @@ -209,3 +233,25 @@ class OneConstructUse { return room.getEventById(msgId!); } } + +class AggregateConstructUses { + final List _lemmaUses; + + AggregateConstructUses({required List lemmaUses}) + : _lemmaUses = lemmaUses; + + String get lemma { + assert( + _lemmaUses.isNotEmpty && + _lemmaUses.every( + (construct) => construct.lemma == _lemmaUses.first.lemma, + ), + ); + return _lemmaUses.first.lemma; + } + + List get uses => _lemmaUses + .map((lemmaUse) => lemmaUse.uses) + .expand((element) => element) + .toList(); +} 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 72% rename from lib/pangea/models/student_analytics_summary_model.dart rename to lib/pangea/models/analytics/summary_analytics_model.dart index 7ed617d91..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,60 +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))) { - _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: json[_lastUpdatedKey] != null - ? DateTime.parse(json[_lastUpdatedKey]) - : null, - ); - } -} diff --git a/lib/pangea/models/analytics_event.dart b/lib/pangea/models/analytics_event.dart deleted file mode 100644 index 0887354cf..000000000 --- a/lib/pangea/models/analytics_event.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/models/analytics_model.dart'; -import 'package:fluffychat/pangea/models/constructs_model.dart'; -import 'package:fluffychat/pangea/models/summary_analytics_model.dart'; -import 'package:matrix/matrix.dart'; - -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!; - } -} diff --git a/lib/pangea/models/analytics_model.dart b/lib/pangea/models/analytics_model.dart deleted file mode 100644 index 7e376aa4e..000000000 --- a/lib/pangea/models/analytics_model.dart +++ /dev/null @@ -1,11 +0,0 @@ -abstract class AnalyticsModel { - DateTime? lastUpdated; - String? prevEventId; - DateTime? prevLastUpdated; - - AnalyticsModel({ - this.lastUpdated, - this.prevEventId, - this.prevLastUpdated, - }); -} 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 711b30dc2..01acb9994 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -1,11 +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'; 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/constructs_event.dart b/lib/pangea/models/constructs_event.dart deleted file mode 100644 index 2c81bf949..000000000 --- a/lib/pangea/models/constructs_event.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:fluffychat/pangea/models/analytics_event.dart'; -import 'package:fluffychat/pangea/models/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; - } - - // 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(), - // ); - // } - // } -} diff --git a/lib/pangea/models/constructs_model.dart b/lib/pangea/models/constructs_model.dart deleted file mode 100644 index 2bdf00a81..000000000 --- a/lib/pangea/models/constructs_model.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:fluffychat/pangea/constants/model_keys.dart'; -import 'package:fluffychat/pangea/models/analytics_model.dart'; -import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; -import 'package:flutter/material.dart'; - -import '../enum/construct_type_enum.dart'; - -class ConstructAnalyticsModel extends AnalyticsModel { - ConstructType type; - List uses; - - ConstructAnalyticsModel({ - required this.type, - this.uses = const [], - super.lastUpdated, - super.prevEventId, - super.prevLastUpdated, - }); - - static const _lastUpdatedKey = "lupt"; - static const _usesKey = "uses"; - - factory ConstructAnalyticsModel.fromJson(Map json) { - // try { - // debugger( - // when: - // kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null), - // ); - return ConstructAnalyticsModel( - // 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']), - lastUpdated: json[_lastUpdatedKey] != null - ? DateTime.parse(json[_lastUpdatedKey]) - : null, - prevEventId: json[ModelKey.prevEventId], - prevLastUpdated: json[ModelKey.prevLastUpdated] != null - ? DateTime.parse(json[ModelKey.prevLastUpdated]) - : null, - uses: json[_usesKey] - .values - .map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses)) - .cast() - .toList(), - ); - // } catch (err) { - // debugger(when: kDebugMode); - // rethrow; - // } - } - - toJson() { - final Map usesMap = {}; - for (final use in uses) { - usesMap[use.lemma] = use.toJson(); - } - - return { - // ModelKey.lemma: lemma, - // 'uses': uses.map((use) => use.toJson()).toList(), - 'type': type.string, - _lastUpdatedKey: lastUpdated?.toIso8601String(), - _usesKey: usesMap, - ModelKey.prevEventId: prevEventId, - ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(), - }; - } - - // void addUsesByUseType(List uses) { - // for (final use in uses) { - // if (use.lemma != lemma) { - // throw Exception('lemma mismatch'); - // } - // uses.add(use); - // } - // } -} - -class LemmaConstructsModel { - String lemma; - List uses; - - LemmaConstructsModel({ - required this.lemma, - this.uses = const [], - }); - - factory LemmaConstructsModel.fromJson(Map json) { - return LemmaConstructsModel( - lemma: json[ModelKey.lemma], - uses: (json['uses'] ?? [] as Iterable) - .map( - (use) => use != null ? OneConstructUse.fromJson(use) : null, - ) - .where((element) => element != null) - .cast() - .toList(), - ); - } - - Map toJson() { - return { - ModelKey.lemma: lemma, - 'uses': uses.map((use) => use.toJson()).toList(), - }; - } -} - -enum ConstructUseType { - /// produced in chat by user, igc was run, and we've judged it to be a correct use - wa, - - /// produced in chat by user, igc was run, and we've judged it to be a incorrect use - /// Note: if the IGC match is ignored, this is not counted as an incorrect use - ga, - - /// produced in chat by user and igc was not run - unk, - - /// selected correctly in IT flow - corIt, - - /// encountered as IT distractor and correctly ignored it - ignIt, - - /// encountered as it distractor and selected it - incIt, - - /// encountered in igc match and ignored match - ignIGC, - - /// selected correctly in IGC flow - corIGC, - - /// encountered as distractor in IGC flow and selected it - incIGC, -} - -extension on ConstructUseType { - String get string { - switch (this) { - case ConstructUseType.ga: - return 'ga'; - case ConstructUseType.wa: - return 'wa'; - case ConstructUseType.corIt: - return 'corIt'; - case ConstructUseType.incIt: - return 'incIt'; - case ConstructUseType.ignIt: - return 'ignIt'; - case ConstructUseType.ignIGC: - return 'ignIGC'; - case ConstructUseType.corIGC: - return 'corIGC'; - case ConstructUseType.incIGC: - return 'incIGC'; - case ConstructUseType.unk: - return 'unk'; - } - } - - IconData get icon { - switch (this) { - case ConstructUseType.ga: - return Icons.check; - case ConstructUseType.wa: - return Icons.thumb_up_sharp; - case ConstructUseType.corIt: - return Icons.check; - case ConstructUseType.incIt: - return Icons.close; - case ConstructUseType.ignIt: - return Icons.close; - case ConstructUseType.ignIGC: - return Icons.close; - case ConstructUseType.corIGC: - return Icons.check; - case ConstructUseType.incIGC: - return Icons.close; - case ConstructUseType.unk: - return Icons.help; - } - } -} 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 77279eb8d..000000000 --- a/lib/pangea/models/student_analytics_event.dart +++ /dev/null @@ -1,165 +0,0 @@ -// import 'dart:developer'; - -// import 'package:fluffychat/pangea/extensions/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; -// _messagesToSave = []; -// } - -// Event get event => _event; - -// StudentAnalyticsSummary get content { -// _contentCache ??= StudentAnalyticsSummary.fromJson(event.content); -// return _contentCache!; -// } - -// Future removeEdittedMessages( -// RecentMessageRecord message, -// ) async { -// final List removeIds = await event.room.client.getEditHistory( -// message.chatId, -// message.eventId, -// ); -// if (removeIds.isEmpty) return; -// _messagesToSave.removeWhere( -// (msg) => removeIds.any((e) => e == msg.eventId), -// ); -// content.removeEdittedMessages( -// event.room.client, -// removeIds, -// ); -// } - -// // Future handleNewMessage( -// // RecentMessageRecord message, { -// // isEdit = false, -// // }) async { -// // if (isEdit) { -// // await removeEdittedMessages(message); -// // } -// // // _addMessage(message); -// // _messagesToSave.add(message); -// // debugPrint("messages to save is now: ${_messagesToSave.length}"); - -// // if (DateTime.now().difference(content.lastUpdated).inMinutes > -// // ClassDefaultValues.minutesDelayToUpdateMyAnalytics) { -// // _updateStudentAnalytics(); -// // } -// // } - -// // Future bulkUpdate(List messages) async { -// // // if (event.room.client.userID != _event.stateKey) { -// // // debugger(when: kDebugMode); -// // // ErrorHandler.logError( -// // // m: "should not be in bulkUpdate ${event.room.client.userID} != ${_event.stateKey}", -// // // ); -// // // return; -// // // } -// // for (final message in messages) { -// // await removeEdittedMessages(message); -// // } - -// // _messagesToSave.addAll(messages); -// // await _updateStudentAnalytics(); -// // } - -// // Future _updateStudentAnalytics() async { -// // content.lastUpdated = DateTime.now(); -// // content.addAll(_messagesToSave); -// // _clearMessages(); - -// // await event.room.client.setRoomStateWithKey( -// // event.room.id, -// // _event.type, -// // '', -// // content.toJson(), -// // ); -// // } - -// Future updateStudentAnalytics() async { -// content.lastUpdated = DateTime.now(); -// // content.addAll(_messagesToSave); -// // _clearMessages(); - -// await event.room.client.setRoomStateWithKey( -// event.room.id, -// _event.type, -// '', -// content.toJson(), -// ); -// await event.room.postLoad(); -// } - -// _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; -// } - -// bool isAlreadyAdded(RecentMessageRecord message) { -// return content.messages.any( -// (element) => element.eventId == message.eventId, -// ); -// } -// } 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/models/summary_analytics_event.dart b/lib/pangea/models/summary_analytics_event.dart deleted file mode 100644 index 1d447a854..000000000 --- a/lib/pangea/models/summary_analytics_event.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:fluffychat/pangea/models/analytics_event.dart'; -import 'package:fluffychat/pangea/models/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; - } -} diff --git a/lib/pangea/models/summary_analytics_model.dart b/lib/pangea/models/summary_analytics_model.dart deleted file mode 100644 index 19ef857a9..000000000 --- a/lib/pangea/models/summary_analytics_model.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'dart:convert'; - -import 'package:fluffychat/pangea/constants/model_keys.dart'; -import 'package:fluffychat/pangea/models/analytics_model.dart'; -import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:flutter/foundation.dart'; - -class SummaryAnalyticsModel extends AnalyticsModel { - late List _messages; - - SummaryAnalyticsModel({ - required List messages, - super.lastUpdated, - super.prevEventId, - super.prevLastUpdated, - }) { - _messages = messages; - } - - 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(), - ModelKey.prevEventId: prevEventId, - ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(), - }; - - 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, - lastUpdated: json[_lastUpdatedKey] != null - ? DateTime.parse(json[_lastUpdatedKey]) - : null, - prevEventId: json[ModelKey.prevEventId], - prevLastUpdated: json[ModelKey.prevLastUpdated] != null - ? DateTime.parse(json[ModelKey.prevLastUpdated]) - : null, - ); - } -} diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index b5a2bd770..991a565c8 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -11,7 +11,7 @@ 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'; diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 7829407ed..fde016ee2 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -4,7 +4,7 @@ import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/models/analytics_event.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'; @@ -14,7 +14,7 @@ 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; @@ -73,10 +73,14 @@ class BaseAnalyticsController extends State { final List analyticsEvent = []; for (final analyticsRoom in analyticsRooms) { - final lastSummaryEvent = await analyticsRoom - .getLastAnalyticsEvent(PangeaEventTypes.summaryAnalytics); - final lastConstructEvent = - await analyticsRoom.getLastAnalyticsEvent(PangeaEventTypes.construct); + 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); } diff --git a/lib/pangea/pages/analytics/class_list/class_list.dart b/lib/pangea/pages/analytics/class_list/class_list.dart index 6b0790863..4fb9ed765 100644 --- a/lib/pangea/pages/analytics/class_list/class_list.dart +++ b/lib/pangea/pages/analytics/class_list/class_list.dart @@ -8,7 +8,7 @@ import 'package:matrix/matrix.dart'; import '../../../../widgets/matrix.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'; diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 2a77abc34..d555b4378 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -2,12 +2,11 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:fluffychat/config/app_config.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_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'; @@ -223,7 +222,7 @@ class ConstructListViewState extends State { if (widget.pangeaController.analytics.constructs == null) { return null; } - return widget.pangeaController.myAnalytics + return widget.pangeaController.analytics .aggregateConstructData(widget.pangeaController.analytics.constructs!) .where((lemmaUses) => lemmaUses.uses.isNotEmpty) .sorted((a, b) { 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), ), ); From e3d30c6207d74ca092fd78f4d9f878cc6d117d12 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 7 Jun 2024 14:25:40 -0400 Subject: [PATCH 14/25] fix for last message sent on analytics list tiles --- lib/pangea/models/analytics/chart_analytics_model.dart | 9 +++++++++ lib/pangea/pages/analytics/analytics_list_tile.dart | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pangea/models/analytics/chart_analytics_model.dart b/lib/pangea/models/analytics/chart_analytics_model.dart index 651ec1152..7430ede2f 100644 --- a/lib/pangea/models/analytics/chart_analytics_model.dart +++ b/lib/pangea/models/analytics/chart_analytics_model.dart @@ -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/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 991a565c8..53bd72922 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -122,7 +122,7 @@ class AnalyticsListTileState extends State { Tooltip( message: L10n.of(context)!.timeOfLastMessage, child: Text( - tileData?.lastMessage?.localizedTimeShort(context) ?? "", + tileData?.lastMessageTime?.localizedTimeShort(context) ?? "", style: TextStyle( fontSize: 13, color: Theme.of(context).textTheme.bodyMedium!.color, From 2b4ada320275bdc9baa1135579848579af16efce Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 10 Jun 2024 10:33:38 -0400 Subject: [PATCH 15/25] switch from children / grandchilden to full space tree. Resolve error with data from visible chats also being counted in private chat analytics --- .../message_analytics_controller.dart | 49 +++++++++----- .../general_info_extension.dart | 6 +- .../children_and_parents_extension.dart | 66 +++++-------------- .../pangea_room_extension.dart | 8 +-- .../pages/analytics/base_analytics.dart | 21 +++--- .../class_analytics/class_analytics.dart | 7 ++ 6 files changed, 69 insertions(+), 88 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 43b119087..092d01c2a 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -129,6 +129,15 @@ class AnalyticsController extends BaseController { 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 { @@ -188,10 +197,7 @@ class AnalyticsController extends BaseController { } } - // get a list of all the space's children, including sub-space children - final resp = await space.client.getSpaceHierarchy(space.id); - final List spaceChildrenIds = - resp.rooms.map((room) => room.roomId).toList(); + final List spaceChildrenIds = space.allSpaceChildRoomIds; // filter out the analyics events that don't belong to the space's children final List allAnalyticsEvents = []; @@ -288,22 +294,32 @@ class AnalyticsController extends BaseController { return filtered; } - List filterPrivateChatAnalytics( + Future> filterPrivateChatAnalytics( List unfiltered, Room? space, - ) { - final List directChatIds = - space?.childrenAndGrandChildrenDirectChatIds ?? []; + ) 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) => directChatIds.contains(u.chatId), + (u) => privateChatIds.contains(u.chatId), ); }).toList(); filtered.forEachIndexed( (i, _) => (filtered[i].content).messages.removeWhere( - (u) => !directChatIds.contains(u.chatId), + (u) => !privateChatIds.contains(u.chatId), ), ); return filtered; @@ -369,7 +385,10 @@ class AnalyticsController extends BaseController { if (defaultSelected.type == AnalyticsEntryType.student) { throw "private chat filtering not available for my analytics"; } - return filterPrivateChatAnalytics(unfilteredAnalytics, space); + return await filterPrivateChatAnalytics( + unfilteredAnalytics, + space, + ); case AnalyticsEntryType.space: return filterSpaceAnalytics(unfilteredAnalytics, selected!.id); default: @@ -573,10 +592,7 @@ class AnalyticsController extends BaseController { } } - final resp = await space.client.getSpaceHierarchy(space.id); - final List spaceChildrenIds = - resp.rooms.map((room) => room.roomId).toList(); - + final List spaceChildrenIds = space.allSpaceChildRoomIds; final List allConstructs = []; for (final constructEvent in constructEvents) { final lemmaUses = constructEvent.content.uses; @@ -622,8 +638,7 @@ class AnalyticsController extends BaseController { List unfilteredConstructs, Room parentSpace, ) { - final List directChatIds = - parentSpace.childrenAndGrandChildrenDirectChatIds; + final List directChatIds = []; final List filtered = List.from(unfilteredConstructs); for (final construct in filtered) { 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/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart index e3efc7eca..bbadd6703 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -25,7 +25,6 @@ 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'; @@ -98,11 +97,6 @@ extension PangeaRoom on Room { List get joinedChildrenRoomIds => _joinedChildrenRoomIds; - List get childrenAndGrandChildren => _childrenAndGrandChildren; - - List get childrenAndGrandChildrenDirectChatIds => - _childrenAndGrandChildrenDirectChatIds; - Future> getChildRooms() async => await _getChildRooms(); Future joinSpaceChild(String roomID) async => @@ -115,6 +109,8 @@ extension PangeaRoom on Room { List get pangeaSpaceParents => _pangeaSpaceParents; + List get allSpaceChildRoomIds => _allSpaceChildRoomIds; + // class_and_exchange_settings DateTime? get rulesUpdatedAt => _rulesUpdatedAt; diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index fde016ee2..fb5295dfe 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -94,15 +94,18 @@ class BaseAnalyticsController extends State { } Future onRefresh() async { - await showFutureLoadingDialog( - context: context, - future: () async { - debugPrint("updating analytics"); - await pangeaController.myAnalytics.updateAnalytics(); - await setChartData(forceUpdate: true); - refreshStream.add(true); - }, - ); + // 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); + }, + ); + }); } Future fetchChartData( diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index f271ba96d..7219e7113 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -70,6 +70,13 @@ class ClassAnalyticsV2Controller extends State { final response = await Matrix.of(context).client.getSpaceHierarchy( classRoom!.id, ); + + // 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( From 0110d3c80247b443c3e57bb1c3dde555d33998e2 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 10 Jun 2024 12:16:02 -0400 Subject: [PATCH 16/25] use msg datetime in toVocabUse --- lib/pangea/models/choreo_record.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index 01acb9994..3422a76a2 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -127,6 +127,7 @@ class ChoreoRecord { List tokens, String chatId, String msgId, + DateTime timestamp, ) { final List uses = []; final DateTime now = DateTime.now(); @@ -141,7 +142,7 @@ class ChoreoRecord { OneConstructUse( useType: type, chatId: chatId, - timeStamp: now, + timeStamp: timestamp, lemma: lemma.text, form: lemma.form, msgId: msgId, From c6186fcdc9f92c456e43c2bb43f3c455a53f09bb Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 12 Jun 2024 15:16:14 -0400 Subject: [PATCH 17/25] update analytics in lastUpdate is null --- lib/pangea/controllers/my_analytics_controller.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index c9c9def40..cd95dd002 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -36,7 +36,7 @@ class MyAnalyticsController extends BaseController { DateTime? lastUpdated = await _pangeaController.analytics .myAnalyticsLastUpdated(PangeaEventTypes.summaryAnalytics); final DateTime yesterday = DateTime.now().subtract(const Duration(days: 1)); - if (lastUpdated?.isBefore(yesterday) ?? false) { + if (lastUpdated?.isBefore(yesterday) ?? true) { debugPrint("analytics out-of-date, updating"); await updateAnalytics(); lastUpdated = await _pangeaController.analytics @@ -82,7 +82,7 @@ class MyAnalyticsController extends BaseController { // checks if event from sync update is a message that should have analytics bool eventHasAnalytics(Event event, DateTime? lastUpdated) { - return event.originServerTs.isAfter(lastUpdated ?? DateTime.now()) && + return (lastUpdated == null || event.originServerTs.isAfter(lastUpdated)) && event.type == EventTypes.Message && event.messageType == MessageTypes.Text && !(event.eventId.contains("web") && From e96c0e34dbb355594473b5d93c4187e0de208aac Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 13 Jun 2024 09:09:24 -0400 Subject: [PATCH 18/25] constructs data model updates --- .../message_analytics_controller.dart | 92 ++++---------- .../models/analytics/constructs_event.dart | 31 +---- .../models/analytics/constructs_model.dart | 115 ++++++------------ .../pages/analytics/construct_list.dart | 62 +++++++--- 4 files changed, 105 insertions(+), 195 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 092d01c2a..74e6e256e 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -8,7 +8,6 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/time_span.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/constructs_model.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'; @@ -520,13 +519,11 @@ class AnalyticsController extends BaseController { List? get constructs => _constructs; - Future> allMyConstructs({ - ConstructType? type, - }) async { + Future> allMyConstructs() async { final List analyticsRooms = _pangeaController.matrixState.client.allMyAnalyticsRooms; - List allConstructs = []; + final List allConstructs = []; for (final Room analyticsRoom in analyticsRooms) { final List? roomEvents = (await analyticsRoom.getAnalyticsEvents( @@ -538,17 +535,12 @@ class AnalyticsController extends BaseController { allConstructs.addAll(roomEvents ?? []); } - allConstructs = type == null - ? allConstructs - : allConstructs.where((e) => e.content.type == type).toList(); - final List adminSpaceRooms = await _pangeaController.matrixState.client.teacherRoomIds; for (final construct in allConstructs) { - final lemmaUses = construct.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere((u) => adminSpaceRooms.contains(u.chatId)); - } + construct.content.uses.removeWhere( + (use) => adminSpaceRooms.contains(use.chatId), + ); } return allConstructs @@ -557,9 +549,8 @@ class AnalyticsController extends BaseController { } Future> allSpaceMemberConstructs( - Room space, { - ConstructType? type, - }) async { + Room space, + ) async { await space.postLoad(); await space.requestParticipants(); final String? langCode = _pangeaController.languageController.activeL2Code( @@ -595,19 +586,16 @@ class AnalyticsController extends BaseController { final List spaceChildrenIds = space.allSpaceChildRoomIds; final List allConstructs = []; for (final constructEvent in constructEvents) { - final lemmaUses = constructEvent.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere((u) => !spaceChildrenIds.contains(u.chatId)); - } + constructEvent.content.uses.removeWhere( + (use) => !spaceChildrenIds.contains(use.chatId), + ); if (constructEvent.content.uses.isNotEmpty) { allConstructs.add(constructEvent); } } - return type == null - ? allConstructs - : allConstructs.where((e) => e.content.type == type).toList(); + return allConstructs; } List filterStudentConstructs( @@ -626,10 +614,7 @@ class AnalyticsController extends BaseController { ) { final List filtered = [...unfilteredConstructs]; for (final construct in filtered) { - final lemmaUses = construct.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere((u) => u.chatId != roomID); - } + construct.content.uses.removeWhere((u) => u.chatId != roomID); } return filtered; } @@ -642,10 +627,9 @@ class AnalyticsController extends BaseController { final List filtered = List.from(unfilteredConstructs); for (final construct in filtered) { - final lemmaUses = construct.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere((u) => !directChatIds.contains(u.chatId)); - } + construct.content.uses.removeWhere( + (use) => !directChatIds.contains(use.chatId), + ); } return filtered; } @@ -664,10 +648,9 @@ class AnalyticsController extends BaseController { List.from(unfilteredConstructs); for (final construct in filtered) { - final lemmaUses = construct.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere((u) => !chatIds.contains(u.chatId)); - } + construct.content.uses.removeWhere( + (use) => !chatIds.contains(use.chatId), + ); } return filtered; @@ -723,9 +706,7 @@ class AnalyticsController extends BaseController { AnalyticsSelected? selected, }) async { final List unfilteredConstructs = - await allMyConstructs( - type: constructType, - ); + await allMyConstructs(); final Room? space = selected?.type == AnalyticsEntryType.space ? _pangeaController.matrixState.client.getRoomById(selected!.id) @@ -748,7 +729,6 @@ class AnalyticsController extends BaseController { final List unfilteredConstructs = await allSpaceMemberConstructs( space, - type: constructType, ); return filterConstructs( @@ -772,12 +752,9 @@ class AnalyticsController extends BaseController { for (int i = 0; i < unfilteredConstructs.length; i++) { final construct = unfilteredConstructs[i]; - final lemmaUses = construct.content.uses; - for (final lemmaUse in lemmaUses) { - lemmaUse.uses.removeWhere( - (u) => u.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate), - ); - } + construct.content.uses.removeWhere( + (use) => use.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate), + ); } unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty); @@ -918,31 +895,6 @@ class AnalyticsController extends BaseController { settingConstructs = false; return _constructs; } - - // used to aggregate ConstructEvents from - // multiple senders (students) with the same lemma - List aggregateConstructData( - List constructs, - ) { - final Map> lemmasToConstructs = {}; - for (final construct in constructs) { - for (final lemmaUses in construct.content.uses) { - lemmasToConstructs[lemmaUses.lemma] ??= []; - lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses); - } - } - - final List aggregatedConstructs = []; - for (final lemmaToConstructs in lemmasToConstructs.entries) { - final List lemmaConstructs = - lemmaToConstructs.value; - final AggregateConstructUses aggregatedData = AggregateConstructUses( - lemmaUses: lemmaConstructs, - ); - aggregatedConstructs.add(aggregatedData); - } - return aggregatedConstructs; - } } abstract class CacheEntry { diff --git a/lib/pangea/models/analytics/constructs_event.dart b/lib/pangea/models/analytics/constructs_event.dart index d297ba893..c2930faba 100644 --- a/lib/pangea/models/analytics/constructs_event.dart +++ b/lib/pangea/models/analytics/constructs_event.dart @@ -1,7 +1,5 @@ -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/analytics_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:matrix/matrix.dart'; import '../../constants/pangea_event_types.dart'; @@ -25,35 +23,8 @@ class ConstructAnalyticsEvent extends AnalyticsEvent { Room analyticsRoom, List uses, ) async { - // create a map of lemmas to their uses - final Map> lemmasToUses = {}; - for (final use in uses) { - if (use.lemma == null) { - ErrorHandler.logError( - e: "use has no lemma in sendConstructsEvent", - s: StackTrace.current, - ); - continue; - } - lemmasToUses[use.lemma!] ??= []; - lemmasToUses[use.lemma]!.add(use); - } - - // convert the map of lemmas to uses into a list of LemmaConstructsModel - // each entry in this list contains one lemma to many uses - final List lemmaUses = lemmasToUses.entries - .map( - (entry) => LemmaConstructsModel( - lemma: entry.key, - uses: entry.value, - ), - ) - .toList(); - - // finally, send the construct analytics event to the analytics room final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel( - type: ConstructType.grammar, - uses: lemmaUses, + uses: uses, ); final String? eventId = await analyticsRoom.sendEvent( diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index a16855c2b..209926f8e 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -1,17 +1,15 @@ -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:flutter/material.dart'; import 'package:matrix/matrix.dart'; import '../../enum/construct_type_enum.dart'; class ConstructAnalyticsModel extends AnalyticsModel { - ConstructType type; - List uses; + List uses; ConstructAnalyticsModel({ - required this.type, this.uses = const [], }); @@ -19,24 +17,16 @@ class ConstructAnalyticsModel extends AnalyticsModel { factory ConstructAnalyticsModel.fromJson(Map json) { return ConstructAnalyticsModel( - type: ConstructTypeUtil.fromString(json['type']), uses: json[_usesKey] - .values - .map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses)) - .cast() + .map((use) => OneConstructUse.fromJson(use)) + .cast() .toList(), ); } toJson() { - final Map usesMap = {}; - for (final use in uses) { - usesMap[use.lemma] = use.toJson(); - } - return { - 'type': type.string, - _usesKey: usesMap, + _usesKey: uses.map((use) => use.toJson()).toList(), }; } @@ -44,53 +34,34 @@ class ConstructAnalyticsModel extends AnalyticsModel { List recentMsgs, ) { final List filtered = List.from(recentMsgs); - final List uses = filtered - .map( - (msg) => msg.originalSent?.choreo?.toGrammarConstructUse( - msg.eventId, - msg.room.id, - msg.originServerTs, - ), - ) - .where((element) => element != null) - .cast>() - .expand((element) => element) - .toList(); + 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; } } -class LemmaConstructsModel { - String lemma; - List uses; - - LemmaConstructsModel({ - required this.lemma, - this.uses = const [], - }); - - factory LemmaConstructsModel.fromJson(Map json) { - return LemmaConstructsModel( - lemma: json[ModelKey.lemma], - uses: (json['uses'] ?? [] as Iterable) - .map( - (use) => use != null ? OneConstructUse.fromJson(use) : null, - ) - .where((element) => element != null) - .cast() - .toList(), - ); - } - - Map toJson() { - return { - ModelKey.lemma: lemma, - 'uses': uses.map((use) => use.toJson()).toList(), - }; - } -} - enum ConstructUseType { /// produced in chat by user, igc was run, and we've judged it to be a correct use wa, @@ -206,7 +177,7 @@ class OneConstructUse { ); } - Map toJson([bool condensed = true]) { + Map toJson([bool condensed = false]) { final Map data = { 'useType': useType.string, 'chatId': chatId, @@ -234,24 +205,14 @@ class OneConstructUse { } } -class AggregateConstructUses { - final List _lemmaUses; +class ConstructUses { + final List uses; + final ConstructType constructType; + final String lemma; - AggregateConstructUses({required List lemmaUses}) - : _lemmaUses = lemmaUses; - - String get lemma { - assert( - _lemmaUses.isNotEmpty && - _lemmaUses.every( - (construct) => construct.lemma == _lemmaUses.first.lemma, - ), - ); - return _lemmaUses.first.lemma; - } - - List get uses => _lemmaUses - .map((lemmaUse) => lemmaUse.uses) - .expand((element) => element) - .toList(); + ConstructUses({ + required this.uses, + required this.constructType, + required this.lemma, + }); } diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index d555b4378..c227491b9 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -116,6 +116,7 @@ class ConstructListView extends StatefulWidget { } class ConstructListViewState extends State { + final ConstructType constructType = ConstructType.grammar; final Map _timelinesCache = {}; final Map _msgEventCache = {}; final List _msgEvents = []; @@ -128,7 +129,7 @@ class ConstructListViewState extends State { refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { widget.pangeaController.analytics .setConstructs( - constructType: ConstructType.grammar, + constructType: constructType, removeIT: true, defaultSelected: widget.defaultSelected, selected: widget.selected, @@ -218,21 +219,46 @@ class ConstructListViewState extends State { } } - List? get constructs { + List? get constructs { if (widget.pangeaController.analytics.constructs == null) { return null; } - return widget.pangeaController.analytics - .aggregateConstructData(widget.pangeaController.analytics.constructs!) - .where((lemmaUses) => lemmaUses.uses.isNotEmpty) - .sorted((a, b) { - final int cmp = b.uses.length.compareTo(a.uses.length); - if (cmp != 0) return cmp; + + final List filtered = + List.from(widget.pangeaController.analytics.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); - }).toList(); + }); + + return constructUses; } - AggregateConstructUses? get currentConstruct => constructs?.firstWhereOrNull( + ConstructUses? get currentConstruct => constructs?.firstWhereOrNull( (element) => element.lemma == widget.controller.currentLemma, ); @@ -456,21 +482,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, ); @@ -498,7 +524,7 @@ class ConstructMessageBubble extends StatelessWidget { vertical: 8, ), child: RichText( - text: (start == null || end == null) + text: (end == null) ? TextSpan( text: errorText, style: defaultStyle, @@ -510,7 +536,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), @@ -529,7 +555,7 @@ class ConstructMessageBubble extends StatelessWidget { ), ), TextSpan( - text: errorText.substring(end!), + text: errorText.substring(end), style: defaultStyle, ), ], From 859c283ba3f7df795ddd5bb9e9893d6d8881e02f Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 13 Jun 2024 13:58:08 -0400 Subject: [PATCH 19/25] added class name to analytics title --- .../pages/analytics/base_analytics.dart | 8 +++++ .../pages/analytics/base_analytics_view.dart | 31 ++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index fb5295dfe..768050082 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -9,6 +9,7 @@ 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'; import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/matrix.dart'; import '../../../widgets/matrix.dart'; import '../../controllers/pangea_controller.dart'; @@ -48,6 +49,13 @@ class BaseAnalyticsController extends State { bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id; + Room? get activeSpace { + if (widget.defaultSelected.type == AnalyticsEntryType.space) { + return Matrix.of(context).client.getRoomById(widget.defaultSelected.id); + } + return null; + } + @override void initState() { super.initState(); diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 151497063..09805ef71 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -62,22 +62,39 @@ class BaseAnalyticsView extends StatelessWidget { style: const TextStyle(decoration: TextDecoration.underline), recognizer: TapGestureRecognizer() ..onTap = () { - if (controller.widget.selectedView == null) return; - String route = + final 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.activeSpace != null) + const TextSpan( + text: " > ", + ), + if (controller.activeSpace != null) + TextSpan( + text: controller.activeSpace!.getLocalizedDisplayname(), + style: const TextStyle(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer() + ..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.widget.selectedView != null) const TextSpan( text: " > ", ), if (controller.widget.selectedView != null) - TextSpan(text: controller.widget.selectedView!.string(context)), + TextSpan( + text: controller.widget.selectedView!.string(context), + ), ], ), overflow: TextOverflow.ellipsis, From c64829cd3cf67a5be7c4e7b8c05171ef587ec2d2 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Thu, 13 Jun 2024 15:48:46 -0400 Subject: [PATCH 20/25] Fix class analytics overflow --- .../pages/analytics/base_analytics_view.dart | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 09805ef71..19d3bcd67 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -164,34 +164,37 @@ class BaseAnalyticsView extends StatelessWidget { CrossAxisAlignment.stretch, children: [ ...controller.widget.tabs[0].items.map( - (item) => AnalyticsListTile( - refreshStream: - controller.refreshStream, - avatar: item.avatar, - defaultSelected: controller - .widget.defaultSelected, - selected: AnalyticsSelected( - item.id, - controller.widget.tabs[0].type, - item.displayName, - ), - isSelected: - controller.isSelected(item.id), - onTap: (_) => - controller.toggleSelection( - AnalyticsSelected( + (item) => Expanded( + child: AnalyticsListTile( + refreshStream: + controller.refreshStream, + avatar: item.avatar, + defaultSelected: controller + .widget.defaultSelected, + selected: AnalyticsSelected( item.id, controller.widget.tabs[0].type, item.displayName, ), + isSelected: controller + .isSelected(item.id), + onTap: (_) => + controller.toggleSelection( + AnalyticsSelected( + item.id, + controller + .widget.tabs[0].type, + item.displayName, + ), + ), + allowNavigateOnSelect: controller + .widget + .tabs[0] + .allowNavigateOnSelect, + pangeaController: + controller.pangeaController, + controller: controller, ), - allowNavigateOnSelect: controller - .widget - .tabs[0] - .allowNavigateOnSelect, - pangeaController: - controller.pangeaController, - controller: controller, ), ), if (controller From c8b38ab0732d111bcc80ad4e53521662f72cf498 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Thu, 13 Jun 2024 16:06:07 -0400 Subject: [PATCH 21/25] fix for construct caching issues with other user's analytics showing up in the logged in user's analytics --- .../message_analytics_controller.dart | 24 ++--- .../models/analytics/constructs_model.dart | 46 +++++++++- .../pages/analytics/base_analytics.dart | 16 +--- .../class_analytics/class_analytics.dart | 1 + .../pages/analytics/construct_list.dart | 90 ++++++++++--------- 5 files changed, 97 insertions(+), 80 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 74e6e256e..c0c8ecd1a 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -514,11 +514,6 @@ class AnalyticsController extends BaseController { //////////////////////////// CONSTRUCTS //////////////////////////// - List? _constructs; - bool settingConstructs = false; - - List? get constructs => _constructs; - Future> allMyConstructs() async { final List analyticsRooms = _pangeaController.matrixState.client.allMyAnalyticsRooms; @@ -782,15 +777,13 @@ class AnalyticsController extends BaseController { } } - Future?> setConstructs({ + Future?> getConstructs({ required ConstructType constructType, required AnalyticsSelected defaultSelected, AnalyticsSelected? selected, bool removeIT = true, bool forceUpdate = false, }) async { - if (settingConstructs) return _constructs; - settingConstructs = true; await _pangeaController.matrixState.client.roomsLoading; Room? space; @@ -807,8 +800,7 @@ class AnalyticsController extends BaseController { "selected": selected, }, ); - settingConstructs = false; - return _constructs; + return []; } await space.postLoad(); langCode = _pangeaController.languageController.activeL2Code( @@ -821,8 +813,7 @@ class AnalyticsController extends BaseController { "space": space, }, ); - settingConstructs = false; - return _constructs; + return []; } } @@ -852,8 +843,6 @@ class AnalyticsController extends BaseController { lastUpdated: lastUpdated, ); if (local != null && !forceUpdate) { - _constructs = local; - settingConstructs = false; return local; } @@ -881,19 +870,16 @@ class AnalyticsController extends BaseController { } } - _constructs = filteredConstructs; - if (local == null) { cacheConstructs( constructType: constructType, - events: _constructs!, + events: filteredConstructs, defaultSelected: defaultSelected, selected: selected, ); } - settingConstructs = false; - return _constructs; + return filteredConstructs; } } diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 209926f8e..6e6bad1b6 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -1,6 +1,7 @@ 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/material.dart'; import 'package:matrix/matrix.dart'; @@ -16,11 +17,48 @@ class ConstructAnalyticsModel extends AnalyticsModel { 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: json[_usesKey] - .map((use) => OneConstructUse.fromJson(use)) - .cast() - .toList(), + uses: uses, ); } diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart index 768050082..2b548226b 100644 --- a/lib/pangea/pages/analytics/base_analytics.dart +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/extensions/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'; @@ -147,26 +146,13 @@ class BaseAnalyticsController extends State { currentLemma = null; selected = isSelected(selectedParam.id) ? null : selectedParam; }); - - await 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 pangeaController.analytics.setConstructs( - constructType: ConstructType.grammar, - defaultSelected: widget.defaultSelected, - selected: selected, - removeIT: true, - ); await setChartData(); refreshStream.add(false); } diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 7219e7113..9bf5ac7a3 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -64,6 +64,7 @@ class ClassAnalyticsV2Controller extends State { Future getChatAndStudents() async { try { + await classRoom?.postLoad(); await classRoom?.requestParticipants(); if (classRoom != null) { diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index c227491b9..c1338d550 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -6,6 +6,7 @@ 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/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'; @@ -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,7 +53,6 @@ class ConstructListState extends State { : Column( children: [ ConstructListView( - init: initialized, controller: widget.controller, pangeaController: widget.pangeaController, defaultSelected: widget.defaultSelected, @@ -94,7 +74,6 @@ 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 bool init; final BaseAnalyticsController controller; final PangeaController pangeaController; final AnalyticsSelected defaultSelected; @@ -103,7 +82,6 @@ class ConstructListView extends StatefulWidget { const ConstructListView({ super.key, - required this.init, required this.controller, required this.pangeaController, required this.defaultSelected, @@ -120,22 +98,46 @@ class ConstructListViewState extends State { final Map _timelinesCache = {}; final Map _msgEventCache = {}; final List _msgEvents = []; + bool fetchingConstructs = true; bool fetchingUses = false; StreamSubscription? refreshSubscription; @override void initState() { super.initState(); + widget.pangeaController.analytics + .getConstructs( + constructType: constructType, + removeIT: true, + defaultSelected: widget.defaultSelected, + selected: widget.selected, + forceUpdate: true, + ) + .whenComplete(() => setState(() => fetchingConstructs = false)) + .then((value) => setState(() => _constructs = value)); + refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { - widget.pangeaController.analytics - .setConstructs( - constructType: constructType, - removeIT: true, - defaultSelected: widget.defaultSelected, - selected: widget.selected, - forceUpdate: true, - ) - .then((_) => setState(() {})); + debugPrint("updating constructs"); + // 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; + debugPrint( + "constructs is now: ${constructs?.map((event) => event.uses.map((use) => use.lemma)).toList()}", + ); + }), + ); + }); }); } @@ -219,18 +221,19 @@ class ConstructListViewState extends State { } } + List? _constructs; + List? get constructs { - if (widget.pangeaController.analytics.constructs == null) { + if (_constructs == null) { return null; } - final List filtered = - List.from(widget.pangeaController.analytics.constructs!) - .map((event) => event.content.uses) - .expand((uses) => uses) - .cast() - .where((use) => use.constructType == constructType) - .toList(); + 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) { @@ -303,7 +306,7 @@ class ConstructListViewState extends State { @override Widget build(BuildContext context) { - if (!widget.init || fetchingUses) { + if (fetchingConstructs || fetchingUses) { return const Expanded( child: Center(child: CircularProgressIndicator()), ); @@ -347,7 +350,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()); } From 3766d28206d39499951ca0a9aba1ef73196cdfe9 Mon Sep 17 00:00:00 2001 From: Kelrap Date: Fri, 14 Jun 2024 10:04:51 -0400 Subject: [PATCH 22/25] Fetched choices don't affect cache --- lib/pangea/repo/span_data_repo.dart | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/pangea/repo/span_data_repo.dart b/lib/pangea/repo/span_data_repo.dart index e253bb1d0..a60f2c4e2 100644 --- a/lib/pangea/repo/span_data_repo.dart +++ b/lib/pangea/repo/span_data_repo.dart @@ -80,15 +80,27 @@ 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.message != span.message) return false; + // if (other.span.context != span.context) 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.message.hashCode, + // span.context.hashCode, + ]); } } From 83d7842e94c456c683267c88a0576c62ab50918d Mon Sep 17 00:00:00 2001 From: Kelrap Date: Fri, 14 Jun 2024 11:38:58 -0400 Subject: [PATCH 23/25] More specific cache calculation --- lib/pangea/repo/span_data_repo.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/pangea/repo/span_data_repo.dart b/lib/pangea/repo/span_data_repo.dart index a60f2c4e2..7a4b378e1 100644 --- a/lib/pangea/repo/span_data_repo.dart +++ b/lib/pangea/repo/span_data_repo.dart @@ -85,6 +85,11 @@ class SpanDetailsRepoReqAndRes { if (other.enableIT != enableIT) return false; if (other.enableIGC != enableIGC) return false; if (other.span.message != span.message) return false; + // if (other.span.shortMessage != span.shortMessage) return false; + if (other.span.offset != span.offset) return false; + if (other.span.length != span.length) return false; + if (other.span.fullText != span.fullText) return false; + // if (other.span.type != span.type) return false; // if (other.span.context != span.context) return false; return true; } @@ -99,6 +104,11 @@ class SpanDetailsRepoReqAndRes { enableIT.hashCode, enableIGC.hashCode, span.message.hashCode, + // span.shortMessage.hashCode, + span.offset.hashCode, + span.length.hashCode, + span.fullText.hashCode, + // span.type.hashCode, // span.context.hashCode, ]); } From df611e5f8277ef19d595a08cff011964c2805f56 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 14 Jun 2024 12:17:57 -0400 Subject: [PATCH 24/25] only show top-level spaces in analytics --- .../models/analytics/constructs_model.dart | 5 +- .../pages/analytics/base_analytics_view.dart | 49 +++++++++---------- .../analytics/class_list/class_list.dart | 19 +++++++ .../analytics/class_list/class_list_view.dart | 47 ++++++++---------- .../pages/analytics/construct_list.dart | 4 -- 5 files changed, 67 insertions(+), 57 deletions(-) diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 6e6bad1b6..18c6d3d5a 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -1,7 +1,10 @@ +import 'dart:developer'; + 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'; @@ -54,7 +57,7 @@ class ConstructAnalyticsModel extends AnalyticsModel { s: s, m: "Error parsing ConstructAnalyticsModel", ); - // debugger(when: kDebugMode); + debugger(when: kDebugMode); } } return ConstructAnalyticsModel( diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart index 19d3bcd67..09805ef71 100644 --- a/lib/pangea/pages/analytics/base_analytics_view.dart +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -164,37 +164,34 @@ class BaseAnalyticsView extends StatelessWidget { CrossAxisAlignment.stretch, children: [ ...controller.widget.tabs[0].items.map( - (item) => Expanded( - child: AnalyticsListTile( - refreshStream: - controller.refreshStream, - avatar: item.avatar, - defaultSelected: controller - .widget.defaultSelected, - selected: AnalyticsSelected( + (item) => AnalyticsListTile( + refreshStream: + controller.refreshStream, + avatar: item.avatar, + defaultSelected: controller + .widget.defaultSelected, + selected: AnalyticsSelected( + item.id, + controller.widget.tabs[0].type, + item.displayName, + ), + isSelected: + controller.isSelected(item.id), + onTap: (_) => + controller.toggleSelection( + AnalyticsSelected( item.id, controller.widget.tabs[0].type, item.displayName, ), - isSelected: controller - .isSelected(item.id), - onTap: (_) => - controller.toggleSelection( - AnalyticsSelected( - item.id, - controller - .widget.tabs[0].type, - item.displayName, - ), - ), - allowNavigateOnSelect: controller - .widget - .tabs[0] - .allowNavigateOnSelect, - pangeaController: - controller.pangeaController, - controller: controller, ), + allowNavigateOnSelect: controller + .widget + .tabs[0] + .allowNavigateOnSelect, + pangeaController: + controller.pangeaController, + controller: controller, ), ), if (controller diff --git a/lib/pangea/pages/analytics/class_list/class_list.dart b/lib/pangea/pages/analytics/class_list/class_list.dart index 4fb9ed765..45aa6bb88 100644 --- a/lib/pangea/pages/analytics/class_list/class_list.dart +++ b/lib/pangea/pages/analytics/class_list/class_list.dart @@ -1,6 +1,7 @@ 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'; @@ -22,6 +23,24 @@ class AnalyticsClassList extends StatefulWidget { class AnalyticsClassListController extends State { PangeaController pangeaController = MatrixState.pangeaController; List models = []; + List spaces = []; + + @override + void initState() { + super.initState(); + Matrix.of(context).client.classesAndExchangesImTeaching.then((spaceList) { + spaceList = spaceList + .where( + (space) => !spaceList.any( + (parentSpace) => parentSpace.spaceChildren + .any((child) => child.roomId == space.id), + ), + ) + .toList(); + spaces = spaceList; + setState(() {}); + }); + } @override Widget build(BuildContext 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 d8cafcf8e..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,31 +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( - defaultSelected: AnalyticsSelected( - snapshot.data![i].id, - AnalyticsEntryType.space, - "", - ), - avatar: snapshot.data![i].avatar, - selected: AnalyticsSelected( - snapshot.data![i].id, - AnalyticsEntryType.space, - snapshot.data![i].name, - ), - onTap: (selected) { - context.go( - '/rooms/analytics/${selected.id}', - ); - }, - allowNavigateOnSelect: true, - isSelected: false, - pangeaController: controller.pangeaController, + 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 c1338d550..e169922ca 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -117,7 +117,6 @@ class ConstructListViewState extends State { .then((value) => setState(() => _constructs = value)); refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) { - debugPrint("updating constructs"); // postframe callback to let widget rebuild with the new selected parameter // before sending selected to getConstructs function WidgetsBinding.instance.addPostFrameCallback((_) { @@ -132,9 +131,6 @@ class ConstructListViewState extends State { .then( (value) => setState(() { _constructs = value; - debugPrint( - "constructs is now: ${constructs?.map((event) => event.uses.map((use) => use.lemma)).toList()}", - ); }), ); }); From 01dc89f199383b96f0ca2940a6faba855c428a6b Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 14 Jun 2024 13:07:26 -0400 Subject: [PATCH 25/25] clear cache on send and before getting language help, update span data hashcode --- .../controllers/igc_controller.dart | 4 +++ lib/pangea/repo/span_data_repo.dart | 31 ++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) 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/repo/span_data_repo.dart b/lib/pangea/repo/span_data_repo.dart index 7a4b378e1..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'; @@ -84,13 +85,16 @@ class SpanDetailsRepoReqAndRes { if (other.userL2 != userL2) return false; if (other.enableIT != enableIT) return false; if (other.enableIGC != enableIGC) return false; - if (other.span.message != span.message) return false; - // if (other.span.shortMessage != span.shortMessage) return false; - if (other.span.offset != span.offset) return false; - if (other.span.length != span.length) return false; - if (other.span.fullText != span.fullText) return false; - // if (other.span.type != span.type) return false; - // if (other.span.context != span.context) 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; } @@ -103,13 +107,12 @@ class SpanDetailsRepoReqAndRes { userL2.hashCode, enableIT.hashCode, enableIGC.hashCode, - span.message.hashCode, - // span.shortMessage.hashCode, - span.offset.hashCode, - span.length.hashCode, - span.fullText.hashCode, - // span.type.hashCode, - // span.context.hashCode, + span.choices + ?.firstWhereOrNull( + (choice) => choice.type == SpanChoiceType.bestCorrection, + ) + ?.value + .hashCode, ]); } }