From 12e364a32dbbb919b51e848b4d2a678532715f79 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Mon, 3 Jun 2024 11:47:33 -0400 Subject: [PATCH] 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,