From 4f073f534ee8641c83422b845aff9e524d0fbd1f Mon Sep 17 00:00:00 2001 From: Gabby Gurdin Date: Fri, 22 Mar 2024 13:17:46 -0400 Subject: [PATCH 1/2] updates to error analytics to ensure data accuracy and allow users to view specific messages related to different lemmas --- assets/l10n/intl_en.arb | 3 +- lib/pages/chat/events/audio_player.dart | 3 - .../chat_list/client_chooser_button.dart | 9 +- lib/pages/new_space/new_space_view.dart | 25 +- lib/pangea/controllers/class_controller.dart | 4 +- .../message_analytics_controller.dart | 702 +++++++++++++----- .../controllers/my_analytics_controller.dart | 6 +- lib/pangea/extensions/client_extension.dart | 79 +- .../extensions/pangea_room_extension.dart | 57 +- .../models/constructs_analytics_model.dart | 13 +- lib/pangea/models/pangea_message_event.dart | 14 + .../pages/analytics/analytics_list_tile.dart | 148 ++-- .../pages/analytics/bar_chart_card.dart | 10 +- .../pages/analytics/base_analytics.dart | 191 +++++ .../pages/analytics/base_analytics_page.dart | 365 --------- .../pages/analytics/base_analytics_view.dart | 302 ++++++++ .../class_analytics/class_analytics.dart | 68 +- .../class_analytics/class_analytics_view.dart | 20 +- .../analytics/class_list/class_list_view.dart | 46 +- .../pages/analytics/construct_list.dart | 514 ++++++++++--- .../pages/analytics/messages_bar_chart.dart | 3 - .../student_analytics/student_analytics.dart | 106 ++- .../student_analytics_view.dart | 21 +- .../pages/analytics/vocab_bar_chart.dart | 348 ++++----- pubspec.yaml | 1 + 25 files changed, 2018 insertions(+), 1040 deletions(-) create mode 100644 lib/pangea/pages/analytics/base_analytics.dart delete mode 100644 lib/pangea/pages/analytics/base_analytics_page.dart create mode 100644 lib/pangea/pages/analytics/base_analytics_view.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 0a636a5667..718ea2386c 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -3948,5 +3948,6 @@ "age": {} } }, - "kickBotWarning": "Kicking Pangea Bot will remove the conversation bot from this chat." + "kickBotWarning": "Kicking Pangea Bot will remove the conversation bot from this chat.", + "joinToView": "Join this room to view details" } \ No newline at end of file diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index a3c5658ab3..56b005d4bd 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -105,9 +105,6 @@ class AudioPlayerState extends State { }); _playAction(); } catch (e, s) { - // #Pangea - debugger(); - // Pangea# Logs().v('Could not download audio file', e, s); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 31aa3010c2..74c3f34b70 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/extensions/client_extension.dart'; import 'package:fluffychat/pangea/utils/class_code.dart'; import 'package:fluffychat/pangea/utils/find_conversation_partner_dialog.dart'; @@ -57,7 +58,11 @@ class ClientChooserButton extends StatelessWidget { ), ), PopupMenuItem( - enabled: matrix.client.classesAndExchangesImTeaching.isNotEmpty, + enabled: matrix.client.rooms.any( + (room) => + room.isSpace && + room.ownPowerLevel >= ClassDefaultValues.powerLevelOfAdmin, + ), value: SettingsAction.classAnalytics, child: Row( children: [ @@ -68,7 +73,7 @@ class ClientChooserButton extends StatelessWidget { ), ), PopupMenuItem( - enabled: matrix.client.classesImIn.isNotEmpty, + enabled: matrix.client.classesAndExchangesImIn.isNotEmpty, value: SettingsAction.myAnalytics, child: Row( children: [ diff --git a/lib/pages/new_space/new_space_view.dart b/lib/pages/new_space/new_space_view.dart index 86ca639c17..71f887ef6b 100644 --- a/lib/pages/new_space/new_space_view.dart +++ b/lib/pages/new_space/new_space_view.dart @@ -1,5 +1,6 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/extensions/client_extension.dart'; +import 'package:fluffychat/pangea/models/class_model.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart'; import 'package:fluffychat/pangea/widgets/class/add_class_and_invite.dart'; import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart'; @@ -142,11 +143,25 @@ class NewSpaceView extends StatelessWidget { ? AddToClassMode.exchange : AddToClassMode.chat, ), - RoomRulesEditor( - key: controller.rulesEditorKey, - roomId: null, - startOpen: false, - initialRules: Matrix.of(context).client.lastUpdatedRoomRules, + FutureBuilder( + future: Matrix.of(context).client.lastUpdatedRoomRules, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return RoomRulesEditor( + key: controller.rulesEditorKey, + roomId: null, + startOpen: false, + initialRules: snapshot.data, + ); + } else { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ), + ); + } + }, ), // SwitchListTile.adaptive( // title: Text(L10n.of(context)!.spaceIsPublic), diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index a1284ec637..f9c5b2ce14 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -35,8 +35,8 @@ class ClassController extends BaseController { Future fixClassPowerLevels() async { try { final List> classFixes = []; - for (final room in _pangeaController - .matrixState.client.classesAndExchangesImTeaching) { + for (final room in (await _pangeaController + .matrixState.client.classesAndExchangesImTeaching)) { classFixes.add(room.setClassPowerlLevels()); } await Future.wait(classFixes); diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 4846276801..9ca8a30c5a 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -1,10 +1,11 @@ import 'dart:developer'; +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/time_span.dart'; -import 'package:fluffychat/pangea/models/headwords.dart'; import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart'; -import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart'; +import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; @@ -22,6 +23,7 @@ class AnalyticsController extends BaseController { late PangeaController _pangeaController; final List _cachedModels = []; + final List _cachedConstructs = []; AnalyticsController(PangeaController pangeaController) : super() { _pangeaController = pangeaController; @@ -50,10 +52,10 @@ class AnalyticsController extends BaseController { .save(_analyticsTimeSpanKey, timeSpan.toString()); } - Future> allClassAnalytics() { + Future> allClassAnalytics() async { final List> classAnalyticFutures = []; - for (final classRoom - in _pangeaController.matrixState.client.classesAndExchangesImTeaching) { + for (final classRoom in (await _pangeaController + .matrixState.client.classesAndExchangesImTeaching)) { classAnalyticFutures.add( getAnalytics(classRoom: classRoom), ); @@ -146,6 +148,7 @@ class AnalyticsController extends BaseController { debugPrint("studentAnalyticsSummaryEvent is null"); } } + final newModel = ChartAnalyticsModel( timeSpan: timeSpan, msgs: msgs, @@ -170,24 +173,6 @@ class AnalyticsController extends BaseController { } } - Future vocabHeadwordsWithTotals( - String langCode, - List vocab, [ - String? chatId, - ]) async { - final VocabHeadwords vocabHeadwords = - await VocabHeadwords.getHeadwords(langCode); - for (final vocabList in vocabHeadwords.lists) { - for (final vocabEvent in vocab) { - vocabList.addVocabUse( - vocabEvent.content.lemma, - vocabEvent.content.uses, - ); - } - } - return vocabHeadwords; - } - Future getAnalyticsForPrivateChats({ TimeSpan? timeSpan, required Room? classRoom, @@ -248,185 +233,570 @@ class AnalyticsController extends BaseController { } } - Future myAnalyticsRoom(String langCode) => - _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode); + ///////////////////////// + // AnalyticsSelected? defaultSelected; + // AnalyticsSelected? selected; + // AnalyticsEntryType? selectedTab; + List? _constructs; + bool settingConstructs = false; - Future> myConstructs(String langCode) async { - final Room analyticsRoom = await myAnalyticsRoom(langCode); + List? get constructs => _constructs; - return analyticsRoom.allConstructEvents; + 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> studentConstructs( - String studentId, - String langCode, - ) { - final Room? analyticsRoom = _pangeaController.matrixState.client - .analyticsRoomLocal(langCode, studentId); - if (analyticsRoom == null) { - ErrorHandler.logError( - m: "analyticsRoom missing in studentConstructs", - s: StackTrace.current, - data: { - "studentId": studentId, - "langCode": langCode, - }, + Future myAnalyticsRoom(String langCode) => + _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode); + + Room? studentAnalyticsRoom(String studentId, String langCode) => + _pangeaController.matrixState.client.analyticsRoomLocal( + langCode, + studentId, ); - } - return analyticsRoom?.allConstructEvents ?? Future.value([]); - } - Future> spaceMemberVocab(String spaceId) async { - await _pangeaController.matrixState.client.roomsLoading; - final Room? space = - _pangeaController.matrixState.client.getRoomById(spaceId); - if (space == null) { - throw Exception("space missing in spaceVocab"); + 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)); } - final String? langCode = space.firstLanguageSettings?.targetLanguage; - final List>> vocabEventFutures = []; + return allConstructs + .where((construct) => construct.content.uses.isNotEmpty) + .toList(); + } + + Future> allSpaceMemberConstructs( + Room space, + String langCode, { + ConstructType? type, + }) async { + final List>> constructEventFutures = []; + await space.postLoad(); await space.requestParticipants(); for (final student in space.students) { final Room? room = _pangeaController.matrixState.client .analyticsRoomLocal(langCode, student.id); - if (room != null) vocabEventFutures.add(room.allConstructEvents); + if (room != null) constructEventFutures.add(room.allConstructEvents); } - final List> allVocabLists = - await Future.wait(vocabEventFutures); + final List> constructLists = + await Future.wait(constructEventFutures); + + final List spaceChildrenIds = space.spaceChildren + .map((e) => e.roomId) + .where((e) => e != null) + .cast() + .toList(); - final List allVocab = []; - for (final vocabList in allVocabLists) { - allVocab.addAll(vocabList); + 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), + ); } - return allVocab; + + return type == null + ? allConstructs + : allConstructs.where((e) => e.content.type == type).toList(); } - /// in student analytics page, the [defaultSelected] is the student - /// in class analytics page, the [defaultSelected] is the class - /// [defaultSelected] should never be a chat - /// the specific [selected] will be those items in the lists - chat, student or class - Future vocabHeadsByAnalyticsSelected({ - required AnalyticsSelected? selected, + List filterStudentConstructs( + List unfilteredConstructs, + String? studentId, + ) { + final List filtered = + List.from(unfilteredConstructs); + filtered.removeWhere((e) => e.event.senderId != studentId); + return filtered; + } + + 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), + ); + return filtered; + } + + 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), + ), + ); + return filtered; + } + + List filterSpaceConstructs( + List unfilteredConstructs, + Room space, + ) { + final List chatIds = space.spaceChildren + .map((e) => e.roomId) + .where((e) => e != null) + .cast() + .toList(); + + List filtered = + List.from(unfilteredConstructs); + filtered = filtered + .where((e) => e.content.uses.any((u) => chatIds.contains(u.chatId))) + .toList(); + + filtered.forEachIndexed( + (i, _) => filtered[i].content.uses.removeWhere( + (u) => !chatIds.contains(u.chatId), + ), + ); + return filtered; + } + + List? getConstructsLocal({ + required TimeSpan timeSpan, + required ConstructType constructType, required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + }) { + final cachedEntry = _cachedConstructs + .firstWhereOrNull( + (e) => + e.timeSpan == timeSpan && + e.type == constructType && + e.defaultSelected.id == defaultSelected.id && + e.defaultSelected.type == defaultSelected.type && + e.selected?.id == selected?.id && + e.selected?.type == selected?.type, + ) + ?.events; + return cachedEntry; + } + + void cacheConstructs({ + required ConstructType constructType, + required List events, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + }) { + _cachedConstructs.add( + ConstructCacheEntry( + timeSpan: currentAnalyticsTimeSpan, + type: constructType, + events: events, + defaultSelected: defaultSelected, + selected: selected, + ), + ); + } + + Future> getMyConstructs({ + required AnalyticsSelected defaultSelected, + required ConstructType constructType, + required String langCode, + AnalyticsSelected? selected, }) async { - Future> eventsFuture; - String langCode; + final List unfilteredConstructs = await allMyConstructs( + langCode, + type: constructType, + ); - if (defaultSelected.type == AnalyticsEntryType.space) { - // as long as a student isn't selected, we want the vocab events for the whole class - final Room? classRoom = - _pangeaController.matrixState.client.getRoomById(defaultSelected.id); - if (classRoom?.classSettings == null) { - throw Exception("classRoom missing in spaceMemberVocab"); - } - langCode = classRoom!.classSettings!.targetLanguage; - eventsFuture = selected?.type == AnalyticsEntryType.student - ? studentConstructs(selected!.id, langCode) - : spaceMemberVocab(defaultSelected.id); - } else if (defaultSelected.type == AnalyticsEntryType.student) { - // in this case, we're on an individual's own analytics page - if (selected?.type == AnalyticsEntryType.space || - selected?.type == AnalyticsEntryType.student) { - langCode = _pangeaController.languageController - .activeL2Code(roomID: selected!.id)!; - eventsFuture = myConstructs(langCode); - } else { - if (_pangeaController.languageController.userL2 == null) { - throw Exception("userL2 missing in vocabHeadsByAnalyticsSelected"); - } - langCode = _pangeaController.languageController.userL2!.langCode; - eventsFuture = myConstructs(langCode); - } - } else { - throw Exception("invalid defaultSelected.type - ${defaultSelected.type}"); - } + final Room? space = selected?.type == AnalyticsEntryType.space + ? _pangeaController.matrixState.client.getRoomById(selected!.id) + : null; - return vocabHeadwordsWithTotals(langCode, await eventsFuture); + return filterConstructs( + unfilteredConstructs: unfilteredConstructs, + langCode: langCode, + space: space, + defaultSelected: defaultSelected, + selected: selected, + ); } - /// in student analytics page, the [defaultSelected] is the student - /// in class analytics page, the [defaultSelected] is the class - /// [defaultSelected] should never be a chat - /// the specific [selected] will be those items in the lists - chat, student or class - Future> constuctEventsByAnalyticsSelected({ - required AnalyticsSelected? selected, - required AnalyticsSelected defaultSelected, + Future> getSpaceConstructs({ required ConstructType constructType, + required Room space, + required AnalyticsSelected defaultSelected, + required String langCode, + AnalyticsSelected? selected, }) async { - late Future> eventFutures; - String? langCode; - if (defaultSelected.type == AnalyticsEntryType.space) { - // as long as a student isn't selected, we want the vocab events for the whole class - final Room? space = - _pangeaController.matrixState.client.getRoomById(defaultSelected.id); - if (space == null) { - throw "No space available"; - } - langCode = space.firstLanguageSettings?.targetLanguage; - if (langCode == null) { - throw "No target language available"; - } + final List unfilteredConstructs = + await allSpaceMemberConstructs( + space, + langCode, + type: constructType, + ); - eventFutures = selected?.type == AnalyticsEntryType.student - ? studentConstructs(selected!.id, langCode) - : spaceMemberVocab(defaultSelected.id); - } else if (defaultSelected.type == AnalyticsEntryType.student) { - // in this case, we're on an individual's own analytics page - - if (selected?.type == AnalyticsEntryType.space || - selected?.type == AnalyticsEntryType.student) { - langCode = _pangeaController.languageController - .activeL2Code(roomID: selected!.id)!; - eventFutures = myConstructs(langCode); - } else { - if (_pangeaController.languageController.userL2 == null) { - throw "userL2 missing in constuctEventsByAnalyticsSelected"; + return filterConstructs( + unfilteredConstructs: unfilteredConstructs, + langCode: langCode, + space: space, + defaultSelected: defaultSelected, + selected: selected, + ); + } + + Future> filterConstructs({ + required List unfilteredConstructs, + required String langCode, + required AnalyticsSelected defaultSelected, + Room? space, + AnalyticsSelected? selected, + }) async { + if ([AnalyticsEntryType.privateChats, AnalyticsEntryType.space] + .contains(selected?.type)) { + assert(space != null); + } + + 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), + ); + } + + unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty); + + switch (selected?.type) { + case null: + return unfilteredConstructs; + case AnalyticsEntryType.student: + if (defaultSelected.type != AnalyticsEntryType.space) { + throw Exception( + "student filtering not available for default filter ${defaultSelected.type}", + ); } - langCode = _pangeaController.languageController.userL2!.langCode; - eventFutures = myConstructs(langCode); - } - } else { - throw "invalid defaultSelected.type - ${defaultSelected.type}"; + final Room? analyticsRoom = + studentAnalyticsRoom(selected!.id, langCode); + if (analyticsRoom == null) { + throw Exception("analyticsRoom missing in filterConstructs"); + } + return filterStudentConstructs(unfilteredConstructs, selected.id); + case AnalyticsEntryType.room: + return filterRoomConstructs(unfilteredConstructs, selected?.id); + case AnalyticsEntryType.privateChats: + return defaultSelected.type == AnalyticsEntryType.student + ? throw "private chat filtering not available for my analytics" + : filterPrivateChatConstructs(unfilteredConstructs, space!); + case AnalyticsEntryType.space: + return filterSpaceConstructs(unfilteredConstructs, space!); + default: + throw Exception("invalid filter type - ${selected?.type}"); } + } - final List events = (await eventFutures) - .where( - (element) => element.content.type == constructType, - ) - .toList(); + Future?> setConstructs({ + required ConstructType constructType, + required AnalyticsSelected defaultSelected, + AnalyticsSelected? selected, + bool removeIT = false, + bool forceUpdate = false, + }) async { + final List? local = getConstructsLocal( + timeSpan: currentAnalyticsTimeSpan, + constructType: constructType, + defaultSelected: defaultSelected, + selected: selected, + ); + if (local != null && !forceUpdate) { + _constructs = local; + return _constructs; + } - final List chatIdsToFilterBy = []; - if (selected?.type == AnalyticsEntryType.room) { - chatIdsToFilterBy.add(selected!.id); - } else if (selected?.type == AnalyticsEntryType.privateChats) { - chatIdsToFilterBy.addAll( - _pangeaController.matrixState.client - .getRoomById(defaultSelected.id) - ?.childrenAndGrandChildrenDirectChatIds ?? - [], - ); - } else if (defaultSelected.type == AnalyticsEntryType.space) { - chatIdsToFilterBy.addAll( - _pangeaController.matrixState.client - .getRoomById(defaultSelected.id) - ?.childrenAndGrandChildren - .where((e) => e.roomId != null) - .map((e) => e.roomId!) ?? - [], + if (settingConstructs) return _constructs; + settingConstructs = true; + await _pangeaController.matrixState.client.roomsLoading; + Room? space; + if (defaultSelected.type == AnalyticsEntryType.space) { + space = _pangeaController.matrixState.client.getRoomById( + defaultSelected.id, ); } - if (chatIdsToFilterBy.isNotEmpty) { - for (final event in events) { - event.content.uses - .removeWhere((u) => !chatIdsToFilterBy.contains(u.chatId)); - } - events.removeWhere((e) => e.content.uses.isEmpty); + + 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": filter?.type, + // "Analytics Entry Id": filter?.id, + // "space": space, + // }, + // ); + throw "langCode missing in getConstructs"; } - return events; + 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 (local == null) { + cacheConstructs( + constructType: constructType, + events: _constructs!, + defaultSelected: defaultSelected, + selected: selected, + ); + } + + settingConstructs = false; + return _constructs; } + + ///////////////////////// + // Future> studentConstructs( + // String studentId, + // String langCode, + // ) { + // final Room? analyticsRoom = _pangeaController.matrixState.client + // .analyticsRoomLocal(langCode, studentId); + // if (analyticsRoom == null) { + // ErrorHandler.logError( + // m: "analyticsRoom missing in studentConstructs", + // s: StackTrace.current, + // data: { + // "studentId": studentId, + // "langCode": langCode, + // }, + // ); + // } + // return analyticsRoom?.allConstructEvents ?? Future.value([]); + // } + + /// in student analytics page, the [defaultSelected] is the student + /// in class analytics page, the [defaultSelected] is the class + /// [defaultSelected] should never be a chat + /// the specific [selected] will be those items in the lists - chat, student or class + // Future> constuctEventsByAnalyticsSelected({ + // required AnalyticsSelected? selected, + // required AnalyticsSelected defaultSelected, + // required ConstructType constructType, + // }) async { + // late Future> eventFutures; + // String? langCode; + // if (defaultSelected.type == AnalyticsEntryType.space) { + // // as long as a student isn't selected, we want the vocab events for the whole class + // final Room? space = + // _pangeaController.matrixState.client.getRoomById(defaultSelected.id); + // if (space == null) { + // throw "No space available"; + // } + // langCode = space.firstLanguageSettings?.targetLanguage; + // if (langCode == null) { + // throw "No target language available"; + // } + + // eventFutures = selected?.type == AnalyticsEntryType.student + // ? studentConstructs(selected!.id, langCode) + // : spaceMemberConstructs(space, langCode); + // } else if (defaultSelected.type == AnalyticsEntryType.student) { + // // in this case, we're on an individual's own analytics page + + // if (selected?.type == AnalyticsEntryType.space || + // selected?.type == AnalyticsEntryType.student) { + // langCode = _pangeaController.languageController + // .activeL2Code(roomID: selected!.id)!; + // eventFutures = myConstructs(langCode); + // } else { + // if (_pangeaController.languageController.userL2 == null) { + // throw "userL2 missing in constuctEventsByAnalyticsSelected"; + // } + // langCode = _pangeaController.languageController.userL2!.langCode; + // eventFutures = myConstructs(langCode); + // } + // } else { + // throw "invalid defaultSelected.type - ${defaultSelected.type}"; + // } + + // final List events = (await eventFutures) + // .where( + // (element) => element.content.type == constructType, + // ) + // .toList(); + + // final List chatIdsToFilterBy = []; + // if (selected?.type == AnalyticsEntryType.room) { + // chatIdsToFilterBy.add(selected!.id); + // } else if (selected?.type == AnalyticsEntryType.privateChats) { + // chatIdsToFilterBy.addAll( + // _pangeaController.matrixState.client + // .getRoomById(defaultSelected.id) + // ?.childrenAndGrandChildrenDirectChatIds ?? + // [], + // ); + // } else if (defaultSelected.type == AnalyticsEntryType.space) { + // chatIdsToFilterBy.addAll( + // _pangeaController.matrixState.client + // .getRoomById(defaultSelected.id) + // ?.childrenAndGrandChildren + // .where((e) => e.roomId != null) + // .map((e) => e.roomId!) ?? + // [], + // ); + // } + // if (chatIdsToFilterBy.isNotEmpty) { + // for (final event in events) { + // event.content.uses + // .removeWhere((u) => !chatIdsToFilterBy.contains(u.chatId)); + // } + // events.removeWhere((e) => e.content.uses.isEmpty); + // } + + // return events; + // } + + // Future vocabHeadwordsWithTotals( + // String langCode, + // List vocab, [ + // String? chatId, + // ]) async { + // final VocabHeadwords vocabHeadwords = + // await VocabHeadwords.getHeadwords(langCode); + // for (final vocabList in vocabHeadwords.lists) { + // for (final vocabEvent in vocab) { + // vocabList.addVocabUse( + // vocabEvent.content.lemma, + // vocabEvent.content.uses, + // ); + // } + // } + // return vocabHeadwords; + // } + + /// in student analytics page, the [defaultSelected] is the student + /// in class analytics page, the [defaultSelected] is the class + /// [defaultSelected] should never be a chat + /// the specific [selected] will be those items in the lists - chat, student or class + // Future vocabHeadsByAnalyticsSelected({ + // required AnalyticsSelected? selected, + // required AnalyticsSelected defaultSelected, + // }) async { + // Future> eventsFuture; + // String langCode; + + // if (defaultSelected.type == AnalyticsEntryType.space) { + // // as long as a student isn't selected, we want the vocab events for the whole class + // final Room? classRoom = + // _pangeaController.matrixState.client.getRoomById(defaultSelected.id); + // if (classRoom?.classSettings == null) { + // throw Exception("classRoom missing in spaceMemberVocab"); + // } + // langCode = classRoom!.classSettings!.targetLanguage; + // eventsFuture = selected?.type == AnalyticsEntryType.student + // ? studentConstructs(selected!.id, langCode) + // : spaceMemberVocab(defaultSelected.id); + // } else if (defaultSelected.type == AnalyticsEntryType.student) { + // // in this case, we're on an individual's own analytics page + // if (selected?.type == AnalyticsEntryType.space || + // selected?.type == AnalyticsEntryType.student) { + // langCode = _pangeaController.languageController + // .activeL2Code(roomID: selected!.id)!; + // eventsFuture = myConstructs(langCode); + // } else { + // if (_pangeaController.languageController.userL2 == null) { + // throw Exception("userL2 missing in vocabHeadsByAnalyticsSelected"); + // } + // langCode = _pangeaController.languageController.userL2!.langCode; + // eventsFuture = myConstructs(langCode); + // } + // } else { + // throw Exception("invalid defaultSelected.type - ${defaultSelected.type}"); + // } + + // return vocabHeadwordsWithTotals(langCode, await eventsFuture); + // } +} + +// this is a cache for the top level constructs data, before filtering +// (either all of a student's analytics, for my analytics, or all of a space's +// analytics, for class analytics) +class ConstructCacheEntry { + final TimeSpan timeSpan; + final ConstructType type; + final List events; + final AnalyticsSelected defaultSelected; + AnalyticsSelected? selected; + + ConstructCacheEntry({ + required this.timeSpan, + required this.type, + required this.events, + required this.defaultSelected, + this.selected, + }); } class CacheModel { diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index b95520aa79..a33748fdaf 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -47,7 +47,6 @@ class MyAnalyticsController { final List events = await analyticsEvents(spaces); for (final event in events) { - debugPrint("adding to total ${event?.content.messages.length}"); if (event != null) { event.handleNewMessage(messageRecord); } @@ -69,9 +68,10 @@ class MyAnalyticsController { return Future.wait(events); } - Future> allMyAnalyticsEvents() => + Future> allMyAnalyticsEvents() async => analyticsEvents( - _pangeaController.matrixState.client.classesAndExchangesImStudyingIn, + await _pangeaController + .matrixState.client.classesAndExchangesImStudyingIn, ); Future saveConstructsMixed( diff --git a/lib/pangea/extensions/client_extension.dart b/lib/pangea/extensions/client_extension.dart index ea0c709d7f..f5a32c95ee 100644 --- a/lib/pangea/extensions/client_extension.dart +++ b/lib/pangea/extensions/client_extension.dart @@ -24,13 +24,22 @@ extension PangeaClient on Client { ) .toList(); - List get classesAndExchangesImTeaching => rooms - .where( - (e) => - (e.isPangeaClass || e.isExchange) && - e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); + Future> get classesAndExchangesImTeaching async { + for (final Room space in rooms.where((room) => room.isSpace)) { + if (space.getState(EventTypes.RoomPowerLevels) == null) { + await space.postLoad(); + } + } + + final spaces = rooms + .where( + (e) => + (e.isPangeaClass || e.isExchange) && + e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + return spaces; + } List get classesImIn => rooms .where( @@ -40,20 +49,43 @@ extension PangeaClient on Client { ) .toList(); - List get classesAndExchangesImStudyingIn => rooms - .where( - (e) => - (e.isPangeaClass || e.isExchange) && - e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, - ) - .toList(); + Future> get classesAndExchangesImStudyingIn async { + for (final Room space in rooms.where((room) => room.isSpace)) { + if (space.getState(EventTypes.RoomPowerLevels) == null) { + await space.postLoad(); + } + } + + final spaces = rooms + .where( + (e) => + (e.isPangeaClass || e.isExchange) && + e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin, + ) + .toList(); + return spaces; + } List get classesAndExchangesImIn => rooms.where((e) => e.isPangeaClass || e.isExchange).toList(); + Future> get teacherRoomIds async { + final List adminRoomIds = []; + for (final Room adminSpace in (await classesAndExchangesImTeaching)) { + adminRoomIds.add(adminSpace.id); + final children = adminSpace.childrenAndGrandChildren; + final List adminSpaceRooms = children + .where((e) => e.roomId != null) + .map((e) => e.roomId!) + .toList(); + adminRoomIds.addAll(adminSpaceRooms); + } + return adminRoomIds; + } + Future> get myTeachers async { final List teachers = []; - for (final classRoom in classesImIn) { + for (final classRoom in classesAndExchangesImIn) { for (final teacher in await classRoom.teachers) { if (!teachers.any((e) => e.id == teacher.id)) { teachers.add(teacher); @@ -68,7 +100,7 @@ extension PangeaClient on Client { ]) async { try { final List> updateFutures = []; - for (final classRoom in classesImIn) { + for (final classRoom in classesAndExchangesImIn) { updateFutures .add(classRoom.updateMyLearningAnalyticsForClass(storageService)); } @@ -152,13 +184,14 @@ extension PangeaClient on Client { return getRoomById(roomId)!; } - PangeaRoomRules? get lastUpdatedRoomRules => classesAndExchangesImTeaching - .where((space) => space.rulesUpdatedAt != null) - .sorted( - (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), - ) - .firstOrNull - ?.pangeaRoomRules; + Future get lastUpdatedRoomRules async => + (await classesAndExchangesImTeaching) + .where((space) => space.rulesUpdatedAt != null) + .sorted( + (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), + ) + .firstOrNull + ?.pangeaRoomRules; ClassSettingsModel? get lastUpdatedClassSettings => classesImTeaching .where((space) => space.classSettingsUpdatedAt != null) diff --git a/lib/pangea/extensions/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension.dart index b45f7bf667..e617247ded 100644 --- a/lib/pangea/extensions/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension.dart @@ -170,6 +170,24 @@ extension PangeaRoom on Room { } //note this only will return rooms that the user has joined or been invited to + List get joinedChildren { + if (!isSpace) return []; + return spaceChildren + .where((child) => child.roomId != null) + .map( + (child) => client.getRoomById(child.roomId!), + ) + .where((child) => child != null) + .cast() + .where( + (child) => child.membership == Membership.join, + ) + .toList(); + } + + List get joinedChildrenRoomIds => + joinedChildren.map((child) => child.id).toList(); + List get childrenAndGrandChildren { if (!isSpace) return []; final List kids = []; @@ -331,7 +349,6 @@ extension PangeaRoom on Room { bool forcedUpdate = false, }) async { try { - debugPrint("getStudentAnalytics $studentId"); if (!isSpace) { debugger(when: kDebugMode); throw Exception("calling getStudentAnalyticsLocal on non-space room"); @@ -475,7 +492,7 @@ extension PangeaRoom on Room { if (storageService?.read(migratedAnalyticsKey) ?? false) return; - if (!isPangeaClass) { + if (!isPangeaClass && !isExchange) { throw Exception( "In updateMyLearningAnalyticsForClass with room that is not not a class", ); @@ -558,24 +575,21 @@ extension PangeaRoom on Room { final List msgs = []; for (final event in timeline.events) { if (event.senderId == client.userID && - event.type == EventTypes.Message) { - if (event.content['msgtype'] == MessageTypes.Text) { - final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( - event: event, - timeline: timeline, - ownMessage: true, - ); - msgs.add( - RecentMessageRecord( - eventId: event.eventId, - chatId: id, - useType: pMsgEvent.useType, - time: event.originServerTs, - ), - ); - } else { - debugger(when: kDebugMode); - } + event.type == EventTypes.Message && + event.content['msgtype'] == MessageTypes.Text) { + final PangeaMessageEvent pMsgEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: true, + ); + msgs.add( + RecentMessageRecord( + eventId: event.eventId, + chatId: id, + useType: pMsgEvent.useType, + time: event.originServerTs, + ), + ); } } return msgs; @@ -702,7 +716,6 @@ extension PangeaRoom on Room { Future> get allConstructEvents async { await postLoad(); - return states[PangeaEventTypes.vocab] ?.values .map((Event event) => ConstructEvent(event: event)) @@ -753,7 +766,7 @@ extension PangeaRoom on Room { .map((e) => e.id), BotName.byEnvironment, ]; - for (final teacher in await client.myTeachers) { + for (final teacher in (await client.myTeachers)) { if (!toAdd.contains(teacher.id)) { debugPrint("inviting ${teacher.id} to analytics room"); await invite(teacher.id); diff --git a/lib/pangea/models/constructs_analytics_model.dart b/lib/pangea/models/constructs_analytics_model.dart index 672467d563..0d940ab80f 100644 --- a/lib/pangea/models/constructs_analytics_model.dart +++ b/lib/pangea/models/constructs_analytics_model.dart @@ -1,9 +1,10 @@ import 'dart:developer'; +import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/constants/model_keys.dart'; import '../enum/construct_type_enum.dart'; class ConstructUses { @@ -178,4 +179,14 @@ class OneConstructUse { 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/pangea_message_event.dart b/lib/pangea/models/pangea_message_event.dart index 288b4b8991..ebc8c2665d 100644 --- a/lib/pangea/models/pangea_message_event.dart +++ b/lib/pangea/models/pangea_message_event.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; import 'package:fluffychat/pangea/models/class_model.dart'; import 'package:fluffychat/pangea/models/message_data_models.dart'; +import 'package:fluffychat/pangea/models/pangea_match_model.dart'; import 'package:fluffychat/pangea/models/pangea_representation_event.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; @@ -470,6 +471,19 @@ class PangeaMessageEvent { return langCode ?? LanguageKeys.unknownLanguage; } + PangeaMatch? firstErrorStep(String lemma) { + final RepresentationEvent? repEvent = originalSent ?? originalWritten; + if (repEvent?.choreo == null) return null; + + final PangeaMatch? step = repEvent!.choreo!.choreoSteps + .firstWhereOrNull( + (element) => + element.acceptedOrIgnoredMatch?.match.shortMessage == lemma, + ) + ?.acceptedOrIgnoredMatch; + return step; + } + // List get activities => //each match is turned into an activity that other students can access //they're not told the answer but have to find it themselves diff --git a/lib/pangea/pages/analytics/analytics_list_tile.dart b/lib/pangea/pages/analytics/analytics_list_tile.dart index 1120d31d5b..ae81e957c0 100644 --- a/lib/pangea/pages/analytics/analytics_list_tile.dart +++ b/lib/pangea/pages/analytics/analytics_list_tile.dart @@ -1,18 +1,17 @@ +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import '../../../../utils/date_time_extension.dart'; import '../../../widgets/avatar.dart'; import '../../../widgets/matrix.dart'; import '../../models/chart_analytics_model.dart'; -import 'base_analytics_page.dart'; +import 'base_analytics.dart'; import 'list_summary_analytics.dart'; -class AnalyticsListTile extends StatelessWidget { +class AnalyticsListTile extends StatefulWidget { const AnalyticsListTile({ super.key, required this.model, @@ -20,9 +19,11 @@ class AnalyticsListTile extends StatelessWidget { required this.avatar, required this.type, required this.id, + required this.allowNavigateOnSelect, required this.selected, required this.onTap, - required this.allowNavigateOnSelect, + this.enabled = true, + this.showSpaceAnalytics = true, }); final Uri? avatar; @@ -30,78 +31,95 @@ class AnalyticsListTile extends StatelessWidget { final AnalyticsEntryType type; final String id; final ChartAnalyticsModel? model; - final bool selected; final bool allowNavigateOnSelect; - final void Function(AnalyticsSelected) onTap; + final bool selected; + final bool enabled; + final bool showSpaceAnalytics; + + @override + AnalyticsListTileState createState() => AnalyticsListTileState(); +} +class AnalyticsListTileState extends State { @override Widget build(BuildContext context) { - final Room? room = Matrix.of(context).client.getRoomById(id); + final Room? room = Matrix.of(context).client.getRoomById(widget.id); return Material( - color: selected + color: widget.selected ? Theme.of(context).colorScheme.secondaryContainer : Colors.transparent, - child: ListTile( - leading: type == AnalyticsEntryType.privateChats - ? CircleAvatar( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - radius: Avatar.defaultSize / 2, - child: const Icon(Icons.forum), - ) - : Avatar( - mxContent: avatar, - name: displayName, - littleIcon: room?.roomTypeIcon, - ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - softWrap: false, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.bodyLarge!.color, + child: Opacity( + opacity: widget.enabled ? 1 : 0.5, + child: Tooltip( + message: widget.enabled ? "" : L10n.of(context)!.joinToView, + 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, + ), + ), ), - ), - ), - Tooltip( - message: L10n.of(context)!.timeOfLastMessage, - child: Text( - model?.lastMessage?.localizedTimeShort(context) ?? "", - style: TextStyle( - fontSize: 13, - color: Theme.of(context).textTheme.bodyMedium!.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, + ), + ), ), - ), + ], ), - ], - ), - subtitle: ListSummaryAnalytics( - chartAnalytics: model, + subtitle: widget.showSpaceAnalytics || !(room?.isSpace ?? false) + ? ListSummaryAnalytics( + chartAnalytics: widget.model, + ) + : null, + selected: widget.selected, + enabled: widget.enabled, + 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, + ), ), - selected: selected, - onTap: () => (room?.isSpace ?? false) && allowNavigateOnSelect - ? context.go( - '/rooms/analytics/${room!.id}', - ) - : onTap( - AnalyticsSelected( - id, - type, - displayName, - ), - ), - trailing: (room?.isSpace ?? false) && - type != AnalyticsEntryType.privateChats && - allowNavigateOnSelect - ? const Icon(Icons.chevron_right) - : null, ), ); } diff --git a/lib/pangea/pages/analytics/bar_chart_card.dart b/lib/pangea/pages/analytics/bar_chart_card.dart index a81f3e5c5a..8fca443d06 100644 --- a/lib/pangea/pages/analytics/bar_chart_card.dart +++ b/lib/pangea/pages/analytics/bar_chart_card.dart @@ -1,17 +1,14 @@ -import 'package:flutter/material.dart'; - import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; class BarChartCard extends StatelessWidget { const BarChartCard({ super.key, - required this.barChartTitle, required this.barChart, required this.legend, required this.loadingData, }); - final String barChartTitle; final BarChart? barChart; final Widget legend; final bool loadingData; @@ -28,11 +25,6 @@ class BarChartCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - const SizedBox(height: 6), - Text( - barChartTitle, - style: Theme.of(context).textTheme.bodyMedium, - ), const SizedBox(height: 14), Expanded( child: loadingData || barChart == null diff --git a/lib/pangea/pages/analytics/base_analytics.dart b/lib/pangea/pages/analytics/base_analytics.dart new file mode 100644 index 0000000000..7182bc6aba --- /dev/null +++ b/lib/pangea/pages/analytics/base_analytics.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/enum/construct_type_enum.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:matrix/matrix.dart'; + +import '../../../widgets/matrix.dart'; +import '../../controllers/pangea_controller.dart'; +import '../../enum/bar_chart_view_enum.dart'; +import '../../enum/time_span.dart'; +import '../../models/chart_analytics_model.dart'; + +class BaseAnalyticsPage extends StatefulWidget { + final String pageTitle; + final List tabs; + final Future Function(BuildContext) refreshData; + + final AnalyticsSelected defaultSelected; + final AnalyticsSelected? alwaysSelected; + final StudentAnalyticsController? myAnalyticsController; + + const BaseAnalyticsPage({ + super.key, + required this.pageTitle, + required this.tabs, + required this.refreshData, + required this.alwaysSelected, + required this.defaultSelected, + this.myAnalyticsController, + }); + + @override + State createState() => BaseAnalyticsController(); +} + +class BaseAnalyticsController extends State { + final PangeaController pangeaController = MatrixState.pangeaController; + BarChartViewSelection? selectedView; + AnalyticsSelected? selected; + String? currentLemma; + + bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id; + + ChartAnalyticsModel? chartData( + BuildContext context, + AnalyticsSelected? selectedParam, + ) { + final AnalyticsSelected analyticsSelected = + selectedParam ?? widget.defaultSelected; + + if (analyticsSelected.type == AnalyticsEntryType.privateChats) { + return pangeaController.analytics.getAnalyticsLocal( + classId: analyticsSelected.id, + chatId: AnalyticsEntryType.privateChats.toString(), + ); + } + + String? chatId = analyticsSelected.type == AnalyticsEntryType.room + ? analyticsSelected.id + : null; + chatId ??= widget.alwaysSelected?.type == AnalyticsEntryType.room + ? widget.alwaysSelected?.id + : null; + + 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, + ); + + return data; + } + + TimeSpan get currentTimeSpan => + pangeaController.analytics.currentAnalyticsTimeSpan; + + void navigate() { + if (currentLemma != null) { + setCurrentLemma(null); + } else if (selectedView != null) { + setSelectedView(null); + } else { + Navigator.of(context).pop(); + } + } + + void toggleSelection(AnalyticsSelected selectedParam) { + setState(() { + debugPrint("selectedParam.id is ${selectedParam.id}"); + currentLemma = null; + selected = isSelected(selectedParam.id) ? null : selectedParam; + }); + pangeaController.analytics.setConstructs( + constructType: ConstructType.grammar, + defaultSelected: widget.defaultSelected, + selected: selected, + removeIT: true, + ); + Future.delayed(Duration.zero, () => setState(() {})); + } + + Future toggleTimeSpan(BuildContext context, TimeSpan timeSpan) async { + await pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan); + await widget.refreshData(context); + await pangeaController.analytics.setConstructs( + constructType: ConstructType.grammar, + defaultSelected: widget.defaultSelected, + selected: selected, + removeIT: true, + ); + setState(() {}); + } + + void setSelectedView(BarChartViewSelection? view) { + currentLemma = null; + selectedView = view; + if (!enableSelection(selected)) { + toggleSelection(selected!); + } + setState(() {}); + } + + void setCurrentLemma(String? lemma) { + currentLemma = lemma; + setState(() {}); + } + + bool enableSelection(AnalyticsSelected? selectedParam) { + return selectedView == BarChartViewSelection.grammar && + selectedParam?.type == AnalyticsEntryType.room + ? Matrix.of(context) + .client + .getRoomById(selectedParam!.id) + ?.membership == + Membership.join + : true; + } + + @override + Widget build(BuildContext context) { + return BaseAnalyticsView(controller: this); + } +} + +class TabData { + AnalyticsEntryType type; + IconData icon; + List items; + bool allowNavigateOnSelect; + + TabData({ + required this.type, + required this.items, + required this.icon, + this.allowNavigateOnSelect = true, + }); +} + +class TabItem { + Uri? avatar; + String displayName; + String id; + + TabItem({required this.avatar, required this.displayName, required this.id}); +} + +enum AnalyticsEntryType { student, room, space, privateChats } + +class AnalyticsSelected { + String id; + AnalyticsEntryType type; + String displayName; + + AnalyticsSelected(this.id, this.type, this.displayName); +} diff --git a/lib/pangea/pages/analytics/base_analytics_page.dart b/lib/pangea/pages/analytics/base_analytics_page.dart deleted file mode 100644 index f17c12e53e..0000000000 --- a/lib/pangea/pages/analytics/base_analytics_page.dart +++ /dev/null @@ -1,365 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; -import '../../../widgets/layouts/max_width_body.dart'; -import '../../../widgets/matrix.dart'; -import '../../controllers/pangea_controller.dart'; -import '../../enum/bar_chart_view_enum.dart'; -import '../../enum/time_span.dart'; -import '../../models/chart_analytics_model.dart'; -import 'analytics_list_tile.dart'; -import 'construct_list.dart'; -import 'messages_bar_chart.dart'; -import 'time_span_menu_button.dart'; - -class BaseAnalyticsPage extends StatefulWidget { - final String pageTitle; - final TabData tabData1; - final TabData tabData2; - final Future Function(BuildContext) refreshData; - - final AnalyticsSelected defaultAnalyticsSelected; - final AnalyticsSelected? alwaysSelected; - - const BaseAnalyticsPage({ - super.key, - required this.pageTitle, - required this.tabData1, - required this.tabData2, - required this.defaultAnalyticsSelected, - required this.refreshData, - required this.alwaysSelected, - }); - - @override - State createState() => BaseAnalyticsController(); -} - -class BaseAnalyticsController extends State { - final PangeaController _pangeaController = MatrixState.pangeaController; - AnalyticsSelected? selected; - BarChartViewSelection selectedView = BarChartViewSelection.grammar; - - @override - void initState() { - super.initState(); - } - - bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id; - - ChartAnalyticsModel? chartData( - BuildContext context, - AnalyticsSelected? selectedParam, - ) { - final AnalyticsSelected analyticsSelected = - selectedParam ?? widget.defaultAnalyticsSelected; - - if (analyticsSelected.type == AnalyticsEntryType.privateChats) { - return _pangeaController.analytics.getAnalyticsLocal( - classId: analyticsSelected.id, - chatId: AnalyticsEntryType.privateChats.toString(), - ); - } - - String? chatId = analyticsSelected.type == AnalyticsEntryType.room - ? analyticsSelected.id - : null; - chatId ??= widget.alwaysSelected?.type == AnalyticsEntryType.room - ? widget.alwaysSelected?.id - : null; - - 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, - ); - return data; - } - - String barTitle(BuildContext context) => - "${selectedView.string(context)}: ${selected == null ? widget.defaultAnalyticsSelected.displayName : selected!.displayName}"; - - TimeSpan get currentTimeSpan => - _pangeaController.analytics.currentAnalyticsTimeSpan; - - void toggleSelection(AnalyticsSelected selectedParam) { - setState(() { - debugPrint("selectedParam.id is ${selectedParam.id}"); - selected = isSelected(selectedParam.id) ? null : selectedParam; - }); - Future.delayed(Duration.zero, () => setState(() {})); - } - - void toggleTimeSpan(BuildContext context, TimeSpan timeSpan) { - _pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan); - setState(() {}); - widget.refreshData(context).then((value) => setState(() {})); - } - - void toggleSelectedView(BarChartViewSelection view) { - selectedView = view; - setState(() {}); - } - - @override - Widget build(BuildContext context) => BaseAnalyticsView(controller: this); -} - -class BaseAnalyticsView extends StatelessWidget { - const BaseAnalyticsView({ - super.key, - required this.controller, - }); - - final BaseAnalyticsController controller; - - Widget chartView(BuildContext context) { - switch (controller.selectedView) { - case BarChartViewSelection.messages: - return MessagesBarChart( - chartAnalytics: controller.chartData(context, controller.selected), - barChartTitle: controller.barTitle(context), - ); - // case BarChartViewSelection.vocab: - // return ConstructList( - // selected: controller.selected, - // defaultSelected: controller.widget.defaultAnalyticsSelected, - // constructType: ConstructType.vocab, - // ); - case BarChartViewSelection.grammar: - return ConstructList( - selected: controller.selected, - defaultSelected: controller.widget.defaultAnalyticsSelected, - constructType: ConstructType.grammar, - title: controller.barTitle(context), - ); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - centerTitle: true, - title: Text( - controller.widget.pageTitle, - style: TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - overflow: TextOverflow.clip, - textAlign: TextAlign.center, - ), - actions: [ - for (final view in BarChartViewSelection.values) - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: controller.selectedView == view - ? AppConfig.primaryColor - : null, - ), - child: IconButton( - isSelected: controller.selectedView == view, - icon: Icon(view.icon), - tooltip: view.string(context), - onPressed: () => controller.toggleSelectedView(view), - ), - ), - TimeSpanMenuButton( - value: controller.currentTimeSpan, - onChange: (TimeSpan value) => - controller.toggleTimeSpan(context, value), - ), - // ChartViewPickerButton( - // selected: controller.selectedView, - // onChange: controller.toggleSelectedView, - // ), - ], - ), - body: MaxWidthBody( - withScrolling: false, - child: Column( - children: [ - Expanded( - flex: 1, - child: chartView(context), - ), - Expanded( - flex: 1, - child: DefaultTabController( - length: 2, - child: Column( - children: [ - TabBar( - tabs: [ - Tab( - icon: Icon( - controller.widget.tabData1.icon, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - Tab( - icon: Icon( - controller.widget.tabData2.icon, - color: - Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - Expanded( - child: SingleChildScrollView( - child: SizedBox( - height: max( - controller.widget.tabData1.items.length + 1, - controller.widget.tabData2.items.length, - ) * - 72, - child: TabBarView( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ...controller.widget.tabData1.items.map( - (item) => AnalyticsListTile( - avatar: item.avatar, - model: controller.chartData( - context, - AnalyticsSelected( - item.id, - controller.widget.tabData1.type, - "", - ), - ), - displayName: item.displayName, - id: item.id, - type: controller.widget.tabData1.type, - selected: controller.isSelected(item.id), - onTap: controller.toggleSelection, - allowNavigateOnSelect: controller.widget - .tabData1.allowNavigateOnSelect, - ), - ), - if (controller.widget.defaultAnalyticsSelected - .type == - AnalyticsEntryType.space) - AnalyticsListTile( - avatar: null, - model: controller.chartData( - context, - AnalyticsSelected( - controller.widget - .defaultAnalyticsSelected.id, - AnalyticsEntryType.privateChats, - L10n.of(context)!.allPrivateChats, - ), - ), - displayName: - L10n.of(context)!.allPrivateChats, - id: controller - .widget.defaultAnalyticsSelected.id, - type: AnalyticsEntryType.privateChats, - selected: controller.isSelected( - controller - .widget.defaultAnalyticsSelected.id, - ), - onTap: controller.toggleSelection, - allowNavigateOnSelect: false, - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: controller.widget.tabData2.items - .map( - (item) => AnalyticsListTile( - avatar: item.avatar, - model: controller.chartData( - context, - AnalyticsSelected( - item.id, - controller.widget.tabData2.type, - "", - ), - ), - displayName: item.displayName, - id: item.id, - type: controller.widget.tabData2.type, - selected: - controller.isSelected(item.id), - onTap: controller.toggleSelection, - allowNavigateOnSelect: controller.widget - .tabData2.allowNavigateOnSelect, - ), - ) - .toList(), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -class TabData { - AnalyticsEntryType type; - IconData icon; - List items; - bool allowNavigateOnSelect; - - TabData({ - required this.type, - required this.items, - required this.icon, - this.allowNavigateOnSelect = true, - }); -} - -class TabItem { - Uri? avatar; - String displayName; - String id; - - TabItem({required this.avatar, required this.displayName, required this.id}); -} - -enum AnalyticsEntryType { student, room, space, privateChats } - -class AnalyticsSelected { - String id; - AnalyticsEntryType type; - String displayName; - - AnalyticsSelected(this.id, this.type, this.displayName); -} diff --git a/lib/pangea/pages/analytics/base_analytics_view.dart b/lib/pangea/pages/analytics/base_analytics_view.dart new file mode 100644 index 0000000000..1f331d2d51 --- /dev/null +++ b/lib/pangea/pages/analytics/base_analytics_view.dart @@ -0,0 +1,302 @@ +import 'dart:math'; + +import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/time_span.dart'; +import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart'; +import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; +import 'package:fluffychat/pangea/pages/analytics/construct_list.dart'; +import 'package:fluffychat/pangea/pages/analytics/messages_bar_chart.dart'; +import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class BaseAnalyticsView extends StatelessWidget { + const BaseAnalyticsView({ + super.key, + required this.controller, + }); + + final BaseAnalyticsController controller; + + Widget chartView(BuildContext context) { + if (controller.selectedView == null) { + return const SizedBox(); + } + + switch (controller.selectedView!) { + case BarChartViewSelection.messages: + return MessagesBarChart( + chartAnalytics: controller.chartData( + context, + controller.selected, + ), + ); + case BarChartViewSelection.grammar: + return ConstructList( + constructType: ConstructType.grammar, + defaultSelected: controller.widget.defaultSelected, + selected: controller.selected, + controller: controller, + pangeaController: controller.pangeaController, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge!.color, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: controller.widget.pageTitle, + style: const TextStyle(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer() + ..onTap = () => controller.selectedView != null + ? controller.setSelectedView(null) + : null, + ), + if (controller.selectedView != null) + const TextSpan( + text: " > ", + ), + if (controller.selectedView != null) + TextSpan( + style: const TextStyle(decoration: TextDecoration.underline), + text: controller.selectedView!.string(context), + recognizer: TapGestureRecognizer() + ..onTap = () => controller.currentLemma != null + ? controller.setCurrentLemma(null) + : null, + ), + if (controller.currentLemma != null) + const TextSpan( + text: " > ", + ), + if (controller.currentLemma != null) + TextSpan( + text: controller.currentLemma, + style: const TextStyle(decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer()..onTap = () {}, + ), + ], + ), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: controller.navigate, + ), + actions: [ + TimeSpanMenuButton( + value: controller.currentTimeSpan, + onChange: (TimeSpan value) => + controller.toggleTimeSpan(context, value), + ), + ], + ), + body: MaxWidthBody( + withScrolling: false, + child: controller.selectedView != null + ? Column( + children: [ + Expanded( + flex: 1, + child: chartView(context), + ), + Expanded( + flex: 1, + child: DefaultTabController( + length: 2, + child: Column( + children: [ + TabBar( + tabs: [ + ...controller.widget.tabs.map( + (tab) => Tab( + icon: Icon( + tab.icon, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ), + ], + ), + Expanded( + child: SingleChildScrollView( + child: SizedBox( + height: max( + controller.widget.tabs[0].items.length + + 1, + controller.widget.tabs[1].items.length, + ) * + 72, + child: TabBarView( + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + ...controller.widget.tabs[0].items.map( + (item) => AnalyticsListTile( + avatar: item.avatar, + model: controller.chartData( + context, + AnalyticsSelected( + item.id, + controller.widget.tabs[0].type, + "", + ), + ), + displayName: item.displayName, + id: item.id, + type: + controller.widget.tabs[0].type, + selected: + controller.isSelected(item.id), + enabled: controller.enableSelection( + AnalyticsSelected( + item.id, + controller.widget.tabs[0].type, + "", + ), + ), + showSpaceAnalytics: false, + onTap: (_) => + controller.toggleSelection( + AnalyticsSelected( + item.id, + controller.widget.tabs[0].type, + item.displayName, + ), + ), + allowNavigateOnSelect: controller + .widget + .tabs[0] + .allowNavigateOnSelect, + ), + ), + if (controller + .widget.defaultSelected.type == + AnalyticsEntryType.space) + AnalyticsListTile( + avatar: null, + model: controller.chartData( + context, + 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( + controller + .widget.defaultSelected.id, + ), + onTap: controller.toggleSelection, + ), + ], + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: controller.widget.tabs[1].items + .map( + (item) => AnalyticsListTile( + avatar: item.avatar, + model: controller.chartData( + context, + AnalyticsSelected( + item.id, + controller + .widget.tabs[1].type, + "", + ), + ), + displayName: item.displayName, + id: item.id, + type: controller + .widget.tabs[1].type, + selected: controller + .isSelected(item.id), + onTap: controller.toggleSelection, + allowNavigateOnSelect: controller + .widget + .tabs[1] + .allowNavigateOnSelect, + ), + ) + .toList(), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ], + ) + : Column( + children: [ + const Divider(height: 1), + ListTile( + title: const Text("Error Analytics"), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: + Theme.of(context).textTheme.bodyLarge!.color, + child: Icon(BarChartViewSelection.grammar.icon), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => controller.setSelectedView( + BarChartViewSelection.grammar, + ), + ), + const Divider(height: 1), + ListTile( + title: const Text("Message Analytics"), + leading: CircleAvatar( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + foregroundColor: + Theme.of(context).textTheme.bodyLarge!.color, + child: Icon(BarChartViewSelection.messages.icon), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => controller.setSelectedView( + BarChartViewSelection.messages, + ), + ), + const Divider(height: 1), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart index 0a3802d8ba..0316d02cd5 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'dart:developer'; import 'package:fluffychat/pangea/constants/pangea_event_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'; @@ -33,9 +35,43 @@ class ClassAnalyticsV2Controller extends State { StreamSubscription? stateSub; Timer? refreshTimer; - List chats = []; + 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(); @@ -43,6 +79,7 @@ class ClassAnalyticsV2Controller extends State { if (classRoom == null || (!(classRoom?.isSpace ?? false))) { context.go('/rooms'); } + stateSub = _pangeaController.matrixState.client.onRoomState.stream .where( (event) => @@ -59,13 +96,16 @@ class ClassAnalyticsV2Controller extends State { await classRoom?.requestParticipants(); if (classRoom != null) { + final response = await Matrix.of(context).client.getSpaceHierarchy( + classRoom!.id, + maxDepth: 1, + ); + students = classRoom!.students; - chats = classRoom!.spaceChildren - .where((element) => element.roomId != null) - .map((e) => Matrix.of(context).client.getRoomById(e.roomId!)) - .where((r) => r != null) - .cast() + chats = response.rooms + .where((room) => room.roomId != classRoom!.id) .toList(); + chats.sort((a, b) => a.roomType == 'm.space' ? -1 : 1); } setState(() { @@ -131,7 +171,7 @@ class ClassAnalyticsV2Controller extends State { analyticsFutures.add( _pangeaController.analytics.getAnalytics( classRoom: classRoom, - chatId: chat.id, + chatId: chat.roomId, forceUpdate: forceUpdate, ), ); @@ -154,18 +194,4 @@ class ClassAnalyticsV2Controller extends State { debugger(when: kDebugMode); } } - - String? get classId => GoRouterState.of(context).pathParameters['classid']; - - Room? _classRoom; - Room? get classRoom { - _classRoom ??= classId != null - ? Matrix.of(context).client.getRoomById(classId!) - : null; - return _classRoom; - } - - String className(BuildContext context) { - return classRoom?.name ?? ""; - } } 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 06a46a5b2e..0c05501721 100644 --- a/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart +++ b/lib/pangea/pages/analytics/class_analytics/class_analytics_view.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import '../../../../utils/matrix_sdk_extensions/matrix_locals.dart'; -import '../base_analytics_page.dart'; +import '../base_analytics.dart'; import 'class_analytics.dart'; class ClassAnalyticsView extends StatelessWidget { @@ -21,10 +19,9 @@ class ClassAnalyticsView extends StatelessWidget { items: controller.chats .map( (room) => TabItem( - avatar: room.avatar, - displayName: - room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), - id: room.id, + avatar: room.avatarUrl, + displayName: room.name ?? "", + id: room.roomId, ), ) .toList(), @@ -46,15 +43,14 @@ class ClassAnalyticsView extends StatelessWidget { return controller.classId != null ? BaseAnalyticsPage( pageTitle: pageTitle, - tabData1: tab1, - tabData2: tab2, - defaultAnalyticsSelected: AnalyticsSelected( + tabs: [tab1, tab2], + refreshData: controller.getChatAndStudentAnalytics, + alwaysSelected: AnalyticsSelected( controller.classId!, AnalyticsEntryType.space, controller.className(context), ), - refreshData: controller.getChatAndStudentAnalytics, - alwaysSelected: AnalyticsSelected( + defaultSelected: AnalyticsSelected( controller.classId!, AnalyticsEntryType.space, controller.className(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 ff9ae7d94f..6a0e7f21fa 100644 --- a/lib/pangea/pages/analytics/class_list/class_list_view.dart +++ b/lib/pangea/pages/analytics/class_list/class_list_view.dart @@ -1,15 +1,13 @@ +import 'package:fluffychat/pangea/extensions/client_extension.dart'; +import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart'; +import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/extensions/client_extension.dart'; -import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart'; -import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart'; import '../../../../widgets/matrix.dart'; import '../../../enum/time_span.dart'; -import '../base_analytics_page.dart'; +import '../base_analytics.dart'; import 'class_list.dart'; class AnalyticsClassListView extends StatelessWidget { @@ -18,8 +16,6 @@ class AnalyticsClassListView extends StatelessWidget { @override Widget build(BuildContext context) { - final List classesAndExchanges = - Matrix.of(context).client.classesAndExchangesImTeaching; return Scaffold( appBar: AppBar( centerTitle: true, @@ -48,25 +44,25 @@ class AnalyticsClassListView extends StatelessWidget { ), body: Column( children: [ - // MessagesBarChart( - // chartAnalytics: controller.chartData(context), - // barChartTitle: "", - // ), Flexible( - child: ListView.builder( - itemCount: classesAndExchanges.length, - itemBuilder: (context, i) => AnalyticsListTile( - avatar: classesAndExchanges[i].avatar, - model: controller.pangeaController.analytics - .getAnalyticsLocal(classId: classesAndExchanges[i].id), - displayName: classesAndExchanges[i].name, - id: classesAndExchanges[i].id, - type: AnalyticsEntryType.space, - selected: false, - onTap: (selected) => context.go( - '/rooms/analytics/${selected.id}', + child: FutureBuilder( + future: Matrix.of(context).client.classesAndExchangesImTeaching, + builder: (context, snapshot) => ListView.builder( + itemCount: snapshot.hasData ? snapshot.data?.length ?? 0 : 0, + itemBuilder: (context, i) => AnalyticsListTile( + avatar: snapshot.data![i].avatar, + model: controller.pangeaController.analytics + .getAnalyticsLocal(classId: snapshot.data![i].id), + displayName: snapshot.data![i].name, + id: snapshot.data![i].id, + type: AnalyticsEntryType.space, + // selected: false, + onTap: (selected) => context.go( + '/rooms/analytics/${selected.id}', + ), + allowNavigateOnSelect: true, + selected: false, ), - allowNavigateOnSelect: true, ), ), ), diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 2128c827f4..d81fe2b384 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -1,30 +1,37 @@ 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/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/construct_analytics_event.dart'; +import 'package:fluffychat/pangea/models/constructs_analytics_model.dart'; +import 'package:fluffychat/pangea/models/pangea_match_model.dart'; +import 'package:fluffychat/pangea/models/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/pangea_representation_event.dart'; +import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/utils/string_color.dart'; +import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; -import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; -import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import '../../constants/pangea_event_types.dart'; -import '../../models/construct_analytics_event.dart'; -import '../../utils/error_handler.dart'; - class ConstructList extends StatefulWidget { - final AnalyticsSelected? selected; - final AnalyticsSelected defaultSelected; final ConstructType constructType; - final String title; + final AnalyticsSelected defaultSelected; + final AnalyticsSelected? selected; + final BaseAnalyticsController controller; + final PangeaController pangeaController; const ConstructList({ super.key, - required this.selected, - required this.defaultSelected, required this.constructType, - required this.title, + required this.defaultSelected, + required this.controller, + required this.pangeaController, + this.selected, }); @override @@ -32,77 +39,27 @@ class ConstructList extends StatefulWidget { } class ConstructListState extends State { - List constructs = []; bool initialized = false; String? langCode; String? error; - StreamSubscription? stateSub; - Timer? refreshTimer; - @override void initState() { super.initState(); - - _updateConstructs(); - - stateSub = MatrixState - .pangeaController.matrixState.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); - } - - void onStateUpdate(Event newState) { - if (!(refreshTimer?.isActive ?? false)) { - refreshTimer = Timer( - const Duration(seconds: 3), - () => _updateConstructs(), - ); - } + widget.pangeaController.analytics + .setConstructs( + constructType: widget.constructType, + removeIT: true, + defaultSelected: widget.defaultSelected, + selected: widget.selected, + forceUpdate: true, + ) + .then((_) => setState(() => initialized = true)); } @override void dispose() { super.dispose(); - refreshTimer?.cancel(); - stateSub?.cancel(); - } - - @override - void didUpdateWidget(ConstructList oldWidget) { - super.didUpdateWidget(oldWidget); - - if (widget.selected?.id != oldWidget.selected?.id) { - _updateConstructs(); - } - } - - void _updateConstructs() { - setState(() { - initialized = false; - }); - MatrixState.pangeaController.analytics - .constuctEventsByAnalyticsSelected( - selected: widget.selected, - defaultSelected: widget.defaultSelected, - constructType: widget.constructType, - ) - .then((value) { - setState(() { - constructs = value; - initialized = true; - error = null; - }); - }).onError((error, stackTrace) { - ErrorHandler.logError(e: error, s: stackTrace); - setState(() { - constructs = []; - initialized = true; - error = error?.toString(); - }); - }); } @override @@ -113,20 +70,12 @@ class ConstructListState extends State { ) : Column( children: [ - Text( - widget.title, - style: Theme.of(context).textTheme.bodyMedium, - ), ConstructListView( - constructs: constructs.where((element) { - debugPrint("element type is ${element.content.type}"); - return element.content.lemma != - "Try interactive translation" && - element.content.lemma != "itStart" && - element.content.lemma != - MatchRuleIds.interactiveTranslation; - }).toList(), init: initialized, + controller: widget.controller, + pangeaController: widget.pangeaController, + defaultSelected: widget.defaultSelected, + selected: widget.selected, ), ], ); @@ -142,39 +91,404 @@ class ConstructListState extends State { // title = construct.content.lemma // subtitle = total uses, equal to construct.content.uses.length // list has a fixed height of 400 and is scrollable -class ConstructListView extends StatelessWidget { - final List constructs; +class ConstructListView extends StatefulWidget { + // final List constructs; final bool init; + final BaseAnalyticsController controller; + final PangeaController pangeaController; + final AnalyticsSelected defaultSelected; + final AnalyticsSelected? selected; const ConstructListView({ super.key, - required this.constructs, required this.init, + required this.controller, + required this.pangeaController, + required this.defaultSelected, + this.selected, }); + @override + State createState() => ConstructListViewState(); +} + +class ConstructListViewState extends State { + final Map _timelinesCache = {}; + final Map _msgEventCache = {}; + final List _msgEvents = []; + bool fetchingUses = false; + + StreamSubscription? stateSub; + Timer? refreshTimer; + + @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(); + }, + ); + } + + @override + void dispose() { + super.dispose(); + refreshTimer?.cancel(); + stateSub?.cancel(); + } + + @override + void didUpdateWidget(ConstructListView oldWidget) { + super.didUpdateWidget(oldWidget); + fetchUses(); + } + + int get lemmaIndex => + constructs?.indexWhere( + (element) => element.content.lemma == widget.controller.currentLemma, + ) ?? + -1; + + Future getMessageEvent( + OneConstructUse use, + ) async { + final Client client = Matrix.of(context).client; + PangeaMessageEvent msgEvent; + if (_msgEventCache.containsKey(use.msgId!)) { + return _msgEventCache[use.msgId!]!; + } + final Room? msgRoom = use.getRoom(client); + if (msgRoom == null || use.msgId == null) { + return null; + } + + Timeline? timeline; + if (_timelinesCache.containsKey(use.chatId)) { + timeline = _timelinesCache[use.chatId]; + } else { + timeline = await msgRoom.getTimeline(); + _timelinesCache[use.chatId] = timeline; + } + + final Event? event = await use.getEvent(client); + if (event == null || timeline == null) { + return null; + } + + msgEvent = PangeaMessageEvent( + event: event, + timeline: timeline, + ownMessage: event.senderId == client.userID, + ); + _msgEventCache[use.msgId!] = msgEvent; + return msgEvent; + } + + Future fetchUses() async { + if (fetchingUses) return; + if (currentConstruct == null) { + setState(() => _msgEvents.clear()); + return; + } + + setState(() => fetchingUses = true); + final List uses = currentConstruct!.content.uses; + _msgEvents.clear(); + + for (final OneConstructUse use in uses) { + final PangeaMessageEvent? msgEvent = await getMessageEvent(use); + final RepresentationEvent? repEvent = + msgEvent?.originalSent ?? msgEvent?.originalWritten; + if (repEvent?.choreo == null) { + continue; + } + _msgEvents.add(msgEvent!); + } + setState(() => fetchingUses = false); + } + + List? get constructs => + widget.pangeaController.analytics.constructs; + + ConstructEvent? get currentConstruct => constructs?.firstWhereOrNull( + (element) => element.content.lemma == widget.controller.currentLemma, + ); + @override Widget build(BuildContext context) { - if (!init) { + if (!widget.init || fetchingUses) { return const Expanded( child: Center(child: CircularProgressIndicator()), ); } - if (constructs.isEmpty) { + if ((constructs?.isEmpty ?? true) || + (widget.controller.currentLemma != null && currentConstruct == null)) { return Expanded( child: Center(child: Text(L10n.of(context)!.noDataFound)), ); } - return Expanded( - child: ListView.builder( - itemCount: constructs.length, - itemBuilder: (context, index) { - return ListTile( - title: Text(constructs[index].content.lemma), - subtitle: Text( - '${L10n.of(context)!.total} ${constructs[index].content.uses.length}', + + return widget.controller.currentLemma == null + ? Expanded( + child: ListView.builder( + itemCount: constructs!.length, + itemBuilder: (context, index) { + return ListTile( + title: Text( + constructs![index].content.lemma, + ), + subtitle: Text( + '${L10n.of(context)!.total} ${constructs![index].content.uses.length}', + ), + onTap: () { + final String lemma = constructs![index].content.lemma; + widget.controller.setCurrentLemma(lemma); + fetchUses(); + }, + ); + }, + ), + ) + : Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (constructs![lemmaIndex].content.uses.length > + _msgEvents.length) + const Center( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Some data may be missing from rooms in which you are not a member.", + ), + ), + ), + Expanded( + child: ListView.separated( + separatorBuilder: (context, index) => + const Divider(height: 1), + itemCount: _msgEvents.length, + itemBuilder: (context, index) { + return ConstructMessage( + msgEvent: _msgEvents[index], + lemma: widget.controller.currentLemma!, + ); + }, + ), + ), + ], ), ); - }, + } +} + +class ConstructMessage extends StatelessWidget { + final PangeaMessageEvent msgEvent; + final String lemma; + + const ConstructMessage({ + super.key, + required this.msgEvent, + required this.lemma, + }); + + @override + Widget build(BuildContext context) { + final PangeaMatch? errorMessage = msgEvent.firstErrorStep(lemma); + if (errorMessage == null) { + return const SizedBox.shrink(); + } + + final String? chosen = errorMessage.match.choices + ?.firstWhereOrNull( + (element) => element.selected == true, + ) + ?.value; + + if (chosen == null) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + ConstructMessageMetadata(msgEvent: msgEvent), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FutureBuilder( + future: msgEvent.event.fetchSenderUser(), + builder: (context, snapshot) { + final displayname = snapshot.data?.calcDisplayname() ?? + msgEvent.event.senderFromMemoryOrFallback + .calcDisplayname(); + return Text( + displayname, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: (Theme.of(context).brightness == + Brightness.light + ? displayname.color + : displayname.lightColorText), + ), + ); + }, + ), + ConstructMessageBubble( + errorText: errorMessage.match.fullText, + replacementText: chosen, + start: errorMessage.match.offset, + end: + errorMessage.match.offset + errorMessage.match.length, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class ConstructMessageBubble extends StatelessWidget { + final String errorText; + final String replacementText; + final int? start; + final int? end; + + const ConstructMessageBubble({ + super.key, + required this.errorText, + required this.replacementText, + this.start, + this.end, + }); + + @override + Widget build(BuildContext context) { + final defaultStyle = TextStyle( + color: Theme.of(context).colorScheme.onBackground, + fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, + height: 1.3, + ); + + return IntrinsicWidth( + child: Material( + color: Theme.of(context).colorScheme.primaryContainer, + clipBehavior: Clip.antiAlias, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(AppConfig.borderRadius), + bottomLeft: Radius.circular(AppConfig.borderRadius), + bottomRight: Radius.circular(AppConfig.borderRadius), + ), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: RichText( + text: (start == null || end == null) + ? TextSpan( + text: errorText, + style: defaultStyle, + ) + : TextSpan( + children: [ + TextSpan( + text: errorText.substring(0, start), + style: defaultStyle, + ), + TextSpan( + text: errorText.substring(start!, end), + style: defaultStyle.merge( + TextStyle( + backgroundColor: Colors.red.withOpacity(0.25), + decoration: TextDecoration.lineThrough, + decorationThickness: 2.5, + ), + ), + ), + const TextSpan(text: " "), + TextSpan( + text: replacementText, + style: defaultStyle.merge( + TextStyle( + backgroundColor: Colors.green.withOpacity(0.25), + ), + ), + ), + TextSpan( + text: errorText.substring(end!), + style: defaultStyle, + ), + ], + ), + ), + ), + ), + ); + } +} + +class ConstructMessageMetadata extends StatelessWidget { + final PangeaMessageEvent msgEvent; + + const ConstructMessageMetadata({ + super.key, + required this.msgEvent, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 30, 0), + child: Column( + children: [ + Text( + msgEvent.event.originServerTs.localizedTime(context), + style: TextStyle(fontSize: 13 * AppConfig.fontSizeFactor), + ), + Text(msgEvent.event.room.name), + ], ), ); } diff --git a/lib/pangea/pages/analytics/messages_bar_chart.dart b/lib/pangea/pages/analytics/messages_bar_chart.dart index 35b3c62e73..d90e44fe46 100644 --- a/lib/pangea/pages/analytics/messages_bar_chart.dart +++ b/lib/pangea/pages/analytics/messages_bar_chart.dart @@ -16,12 +16,10 @@ import 'messages_legend_widget.dart'; class MessagesBarChart extends StatefulWidget { final ChartAnalyticsModel? chartAnalytics; - final String barChartTitle; const MessagesBarChart({ super.key, required this.chartAnalytics, - required this.barChartTitle, }); @override @@ -95,7 +93,6 @@ class MessagesBarChartState extends State { ); return BarChartCard( - barChartTitle: widget.barChartTitle, barChart: barChart, loadingData: widget.chartAnalytics == null, legend: const MessagesLegendsListWidget(), diff --git a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart index 55252bf8ef..fe0e796298 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics.dart @@ -1,18 +1,19 @@ +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'; - import 'package:matrix/matrix.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 '../../../../widgets/matrix.dart'; import '../../../controllers/pangea_controller.dart'; import '../../../extensions/client_extension.dart'; import '../../../utils/sync_status_util_v2.dart'; -import '../base_analytics_page.dart'; +import '../base_analytics.dart'; import 'student_analytics_view.dart'; class StudentAnalyticsPage extends StatefulWidget { @@ -24,16 +25,42 @@ class StudentAnalyticsPage extends StatefulWidget { class StudentAnalyticsController extends State { final PangeaController _pangeaController = MatrixState.pangeaController; - AnalyticsSelected? selected; + StreamSubscription? stateSub; + Timer? refreshTimer; + + List _chats = []; + List _spaces = []; + + void onStateUpdate(Event newState) { + if (!(refreshTimer?.isActive ?? false)) { + refreshTimer = Timer( + const Duration(seconds: 3), + () => getClassAndChatAnalytics(context, true), + ); + } + } @override - void initState() { - _pangeaController.matrixState.client + void dispose() { + super.dispose(); + refreshTimer?.cancel(); + stateSub?.cancel(); + } + + Future initialize() async { + await _pangeaController.matrixState.client .updateMyLearningAnalyticsForAllClassesImIn( _pangeaController.pStoreService, ); - super.initState(); + await getClassAndChatAnalytics(context); + stateSub = _pangeaController.matrixState.client.onRoomState.stream + .where( + (event) => + event.type == PangeaEventTypes.studentAnalyticsSummary && + event.senderId == userId, + ) + .listen(onStateUpdate); } @override @@ -43,55 +70,84 @@ class StudentAnalyticsController extends State { // but this is computationally expensive! // key: UniqueKey(), shimmerChild: const ListPlaceholder(), - onFinish: () { - getClassAndChatAnalytics(context); - }, + onFinish: initialize, child: StudentAnalyticsView(this), ); } - Future getClassAndChatAnalytics(BuildContext context) async { + Future getClassAndChatAnalytics( + BuildContext context, [ + forceUpdate = false, + ]) async { final List> analyticsFutures = []; - for (final chat in chats(context)) { + for (final chat in (await getChats())) { analyticsFutures.add( _pangeaController.analytics.getAnalytics( chatId: chat.id, studentId: userId, + forceUpdate: forceUpdate, ), ); } - for (final space in spaces(context)) { + for (final space in (await getSpaces())) { analyticsFutures.add( _pangeaController.analytics.getAnalytics( classRoom: space, studentId: userId, + forceUpdate: forceUpdate, ), ); } analyticsFutures.add( - _pangeaController.analytics.getAnalytics(studentId: userId), + _pangeaController.analytics.getAnalytics( + studentId: userId, + forceUpdate: forceUpdate, + ), ); await Future.wait(analyticsFutures); setState(() {}); } - List spaces(BuildContext context) { + Future> getSpaces() async { + final List rooms = await _pangeaController + .matrixState.client.classesAndExchangesImStudyingIn; + setState(() => _spaces = rooms); + return rooms; + } + + List? get spaces { try { - return _pangeaController - .matrixState.client.classesAndExchangesImStudyingIn; + if (_spaces.isNotEmpty) return _spaces; + getSpaces(); + return _spaces; } catch (err) { debugger(when: kDebugMode); return []; } } - List chats(BuildContext context) { + 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 { - return Matrix.of(context) - .client - .rooms - .where((r) => !r.isSpace && !r.isAnalyticsRoom) - .toList(); + if (_chats.isNotEmpty) return _chats; + getChats(); + return _chats; } catch (err) { debugger(when: kDebugMode); return []; 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 3cbe86194a..8d88cc6858 100644 --- a/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart +++ b/lib/pangea/pages/analytics/student_analytics/student_analytics_view.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; import '../../../../utils/matrix_sdk_extensions/matrix_locals.dart'; -import '../base_analytics_page.dart'; +import '../base_analytics.dart'; import 'student_analytics.dart'; class StudentAnalyticsView extends StatelessWidget { @@ -13,14 +11,11 @@ class StudentAnalyticsView extends StatelessWidget { @override Widget build(BuildContext context) { - final List chats = controller.chats(context); - final List spaces = controller.spaces(context); - final String pageTitle = L10n.of(context)!.myLearning; final TabData chatTabData = TabData( type: AnalyticsEntryType.room, icon: Icons.chat_bubble_outline, - items: chats + items: (controller.chats ?? []) .map( (c) => TabItem( avatar: c.avatar, @@ -35,7 +30,7 @@ class StudentAnalyticsView extends StatelessWidget { final TabData classTabData = TabData( type: AnalyticsEntryType.space, icon: Icons.workspaces, - items: spaces + items: (controller.spaces ?? []) .map( (c) => TabItem( avatar: c.avatar, @@ -51,15 +46,15 @@ class StudentAnalyticsView extends StatelessWidget { return controller.userId != null ? BaseAnalyticsPage( pageTitle: pageTitle, - tabData1: chatTabData, - tabData2: classTabData, - defaultAnalyticsSelected: AnalyticsSelected( + tabs: [chatTabData, classTabData], + refreshData: controller.getClassAndChatAnalytics, + alwaysSelected: AnalyticsSelected( controller.userId!, AnalyticsEntryType.student, L10n.of(context)!.allChatsAndClasses, ), - refreshData: controller.getClassAndChatAnalytics, - alwaysSelected: AnalyticsSelected( + myAnalyticsController: controller, + defaultSelected: AnalyticsSelected( controller.userId!, AnalyticsEntryType.student, L10n.of(context)!.allChatsAndClasses, diff --git a/lib/pangea/pages/analytics/vocab_bar_chart.dart b/lib/pangea/pages/analytics/vocab_bar_chart.dart index 34dc16a69d..fed972c765 100644 --- a/lib/pangea/pages/analytics/vocab_bar_chart.dart +++ b/lib/pangea/pages/analytics/vocab_bar_chart.dart @@ -1,174 +1,174 @@ -import 'package:flutter/material.dart'; - -import 'package:fl_chart/fl_chart.dart'; - -import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/models/headwords.dart'; -import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'bar_chart_card.dart'; -import 'messages_legend_widget.dart'; - -class VocabBarChart extends StatefulWidget { - final AnalyticsSelected? selected; - final AnalyticsSelected defaultSelected; - - const VocabBarChart({ - super.key, - required this.selected, - required this.defaultSelected, - }); - - @override - State createState() => VocabBarChartState(); -} - -class VocabBarChartState extends State { - final double barSpace = 16; - - final PangeaController _pangeaController = MatrixState.pangeaController; - - @override - initState() { - super.initState(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _pangeaController.analytics.vocabHeadsByAnalyticsSelected( - selected: widget.selected, - defaultSelected: widget.defaultSelected, - ), - builder: ((context, snapshot) => BarChartCard( - barChartTitle: (widget.selected != null - ? widget.selected! - : widget.defaultSelected) - .displayName, - barChart: snapshot.hasData - ? buildBarChart(context, snapshot.data!) - : null, - loadingData: snapshot.connectionState != ConnectionState.done, - legend: const MessagesLegendsListWidget(), - )), - ); - } - - TextStyle titleTextStyle(BuildContext context) => TextStyle( - color: Theme.of(context).textTheme.bodyLarge!.color, - fontSize: 10, - ); - - BarChart buildBarChart(BuildContext context, VocabHeadwords vocabHeadwords) { - final flLine = FlLine( - color: Theme.of(context).dividerColor, - strokeWidth: 1, - ); - - return BarChart( - BarChartData( - alignment: BarChartAlignment.spaceEvenly, - barTouchData: BarTouchData( - enabled: false, - ), - // barTouchData: barTouchData, - titlesData: FlTitlesData( - show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (double value, TitleMeta meta) => - SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - vocabHeadwords.lists[value.toInt()].name, - style: titleTextStyle(context), - ), - ), - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (double value, TitleMeta meta) { - Widget textWidget; - if (value != meta.max) { - textWidget = - Text(meta.formattedValue, style: titleTextStyle(context)); - } else { - textWidget = const Icon(Icons.abc_outlined, size: 14); - } - return SideTitleWidget( - axisSide: meta.axisSide, - child: textWidget, - ); - }, - ), - ), - topTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - gridData: FlGridData( - show: true, - // checkToShowHorizontalLine: (value) => value % 10 == 0, - checkToShowHorizontalLine: (value) => true, - getDrawingHorizontalLine: (value) => flLine, - checkToShowVerticalLine: (value) => false, - getDrawingVerticalLine: (value) => flLine, - ), - borderData: FlBorderData( - show: false, - ), - groupsSpace: barSpace, - barGroups: barChartGroupData(vocabHeadwords), - backgroundColor: Colors.transparent, - ), - swapAnimationDuration: const Duration(milliseconds: 250), - ); - } - - List barChartGroupData(VocabHeadwords vocabHeadwords) { - // sort vocab into lists - // calculate levels based on vocab data - - final List chartData = []; - - vocabHeadwords.lists.asMap().forEach((index, intervalGroup) { - chartData.add( - BarChartGroupData( - x: index, - barsSpace: barSpace, - // barRods: intervalGroup.map(constructBarChartRodData).toList(), - barRods: constructBarChartRodData(intervalGroup), - ), - ); - }); - return chartData; - } - - List constructBarChartRodData(VocabList vocabList) { - final ListTotals listTotals = vocabList.calculuateTotals(); - final y1 = listTotals.low; - final y2 = y1 + listTotals.medium; - final y3 = y2 + listTotals.high; - - return [ - BarChartRodData( - toY: y3.toDouble(), - width: 10.toDouble(), - rodStackItems: [ - BarChartRodStackItem(0, y1.toDouble(), Colors.red), - BarChartRodStackItem(y1.toDouble(), y2.toDouble(), Colors.grey), - BarChartRodStackItem(y2.toDouble(), y3.toDouble(), Colors.green), - ], - borderRadius: BorderRadius.zero, - ), - ]; - } -} +// import 'package:flutter/material.dart'; + +// import 'package:fl_chart/fl_chart.dart'; + +// import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +// import 'package:fluffychat/pangea/models/headwords.dart'; +// import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart'; +// import 'package:fluffychat/widgets/matrix.dart'; +// import 'bar_chart_card.dart'; +// import 'messages_legend_widget.dart'; + +// class VocabBarChart extends StatefulWidget { +// final AnalyticsSelected? selected; +// final AnalyticsSelected defaultSelected; + +// const VocabBarChart({ +// super.key, +// required this.selected, +// required this.defaultSelected, +// }); + +// @override +// State createState() => VocabBarChartState(); +// } + +// class VocabBarChartState extends State { +// final double barSpace = 16; + +// final PangeaController _pangeaController = MatrixState.pangeaController; + +// @override +// initState() { +// super.initState(); +// } + +// @override +// Widget build(BuildContext context) { +// return FutureBuilder( +// future: _pangeaController.analytics.vocabHeadsByAnalyticsSelected( +// selected: widget.selected, +// defaultSelected: widget.defaultSelected, +// ), +// builder: ((context, snapshot) => BarChartCard( +// barChartTitle: (widget.selected != null +// ? widget.selected! +// : widget.defaultSelected) +// .displayName, +// barChart: snapshot.hasData +// ? buildBarChart(context, snapshot.data!) +// : null, +// loadingData: snapshot.connectionState != ConnectionState.done, +// legend: const MessagesLegendsListWidget(), +// )), +// ); +// } + +// TextStyle titleTextStyle(BuildContext context) => TextStyle( +// color: Theme.of(context).textTheme.bodyLarge!.color, +// fontSize: 10, +// ); + +// BarChart buildBarChart(BuildContext context, VocabHeadwords vocabHeadwords) { +// final flLine = FlLine( +// color: Theme.of(context).dividerColor, +// strokeWidth: 1, +// ); + +// return BarChart( +// BarChartData( +// alignment: BarChartAlignment.spaceEvenly, +// barTouchData: BarTouchData( +// enabled: false, +// ), +// // barTouchData: barTouchData, +// titlesData: FlTitlesData( +// show: true, +// bottomTitles: AxisTitles( +// sideTitles: SideTitles( +// showTitles: true, +// reservedSize: 28, +// getTitlesWidget: (double value, TitleMeta meta) => +// SideTitleWidget( +// axisSide: meta.axisSide, +// child: Text( +// vocabHeadwords.lists[value.toInt()].name, +// style: titleTextStyle(context), +// ), +// ), +// ), +// ), +// leftTitles: AxisTitles( +// sideTitles: SideTitles( +// showTitles: true, +// reservedSize: 40, +// getTitlesWidget: (double value, TitleMeta meta) { +// Widget textWidget; +// if (value != meta.max) { +// textWidget = +// Text(meta.formattedValue, style: titleTextStyle(context)); +// } else { +// textWidget = const Icon(Icons.abc_outlined, size: 14); +// } +// return SideTitleWidget( +// axisSide: meta.axisSide, +// child: textWidget, +// ); +// }, +// ), +// ), +// topTitles: AxisTitles( +// sideTitles: SideTitles(showTitles: false), +// ), +// rightTitles: AxisTitles( +// sideTitles: SideTitles(showTitles: false), +// ), +// ), +// gridData: FlGridData( +// show: true, +// // checkToShowHorizontalLine: (value) => value % 10 == 0, +// checkToShowHorizontalLine: (value) => true, +// getDrawingHorizontalLine: (value) => flLine, +// checkToShowVerticalLine: (value) => false, +// getDrawingVerticalLine: (value) => flLine, +// ), +// borderData: FlBorderData( +// show: false, +// ), +// groupsSpace: barSpace, +// barGroups: barChartGroupData(vocabHeadwords), +// backgroundColor: Colors.transparent, +// ), +// swapAnimationDuration: const Duration(milliseconds: 250), +// ); +// } + +// List barChartGroupData(VocabHeadwords vocabHeadwords) { +// // sort vocab into lists +// // calculate levels based on vocab data + +// final List chartData = []; + +// vocabHeadwords.lists.asMap().forEach((index, intervalGroup) { +// chartData.add( +// BarChartGroupData( +// x: index, +// barsSpace: barSpace, +// // barRods: intervalGroup.map(constructBarChartRodData).toList(), +// barRods: constructBarChartRodData(intervalGroup), +// ), +// ); +// }); +// return chartData; +// } + +// List constructBarChartRodData(VocabList vocabList) { +// final ListTotals listTotals = vocabList.calculuateTotals(); +// final y1 = listTotals.low; +// final y2 = y1 + listTotals.medium; +// final y3 = y2 + listTotals.high; + +// return [ +// BarChartRodData( +// toY: y3.toDouble(), +// width: 10.toDouble(), +// rodStackItems: [ +// BarChartRodStackItem(0, y1.toDouble(), Colors.red), +// BarChartRodStackItem(y1.toDouble(), y2.toDouble(), Colors.grey), +// BarChartRodStackItem(y2.toDouble(), y3.toDouble(), Colors.green), +// ], +// borderRadius: BorderRadius.zero, +// ), +// ]; +// } +// } diff --git a/pubspec.yaml b/pubspec.yaml index 79f6894f4e..4c9fde2eaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,6 +2,7 @@ name: fluffychat # #Pangea # description: Chat with your friends. description: Learn a language while texting your friends. +# !!!!!! flutter version: 3.16.9 !!!!!! # Pangea# publish_to: none version: 1.11.2+3453 From 47f15ae323374394957a676368e46b08362aaddd Mon Sep 17 00:00:00 2001 From: Gabby Gurdin Date: Mon, 25 Mar 2024 09:55:29 -0400 Subject: [PATCH 2/2] cleanup of message analytics controller --- .../message_analytics_controller.dart | 188 +----------------- 1 file changed, 9 insertions(+), 179 deletions(-) diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 9ca8a30c5a..d20e0bf36d 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -233,10 +233,6 @@ class AnalyticsController extends BaseController { } } - ///////////////////////// - // AnalyticsSelected? defaultSelected; - // AnalyticsSelected? selected; - // AnalyticsEntryType? selectedTab; List? _constructs; bool settingConstructs = false; @@ -565,15 +561,15 @@ class AnalyticsController extends BaseController { ); if (langCode == null) { - // ErrorHandler.logError( - // m: "langCode missing in getConstructs", - // data: { - // "constructType": constructType, - // "AnalyticsEntryType": filter?.type, - // "Analytics Entry Id": filter?.id, - // "space": space, - // }, - // ); + ErrorHandler.logError( + m: "langCode missing in getConstructs", + data: { + "constructType": constructType, + "AnalyticsEntryType": defaultSelected.type, + "AnalyticsEntryId": defaultSelected.id, + "space": space, + }, + ); throw "langCode missing in getConstructs"; } @@ -615,174 +611,8 @@ class AnalyticsController extends BaseController { settingConstructs = false; return _constructs; } - - ///////////////////////// - // Future> studentConstructs( - // String studentId, - // String langCode, - // ) { - // final Room? analyticsRoom = _pangeaController.matrixState.client - // .analyticsRoomLocal(langCode, studentId); - // if (analyticsRoom == null) { - // ErrorHandler.logError( - // m: "analyticsRoom missing in studentConstructs", - // s: StackTrace.current, - // data: { - // "studentId": studentId, - // "langCode": langCode, - // }, - // ); - // } - // return analyticsRoom?.allConstructEvents ?? Future.value([]); - // } - - /// in student analytics page, the [defaultSelected] is the student - /// in class analytics page, the [defaultSelected] is the class - /// [defaultSelected] should never be a chat - /// the specific [selected] will be those items in the lists - chat, student or class - // Future> constuctEventsByAnalyticsSelected({ - // required AnalyticsSelected? selected, - // required AnalyticsSelected defaultSelected, - // required ConstructType constructType, - // }) async { - // late Future> eventFutures; - // String? langCode; - // if (defaultSelected.type == AnalyticsEntryType.space) { - // // as long as a student isn't selected, we want the vocab events for the whole class - // final Room? space = - // _pangeaController.matrixState.client.getRoomById(defaultSelected.id); - // if (space == null) { - // throw "No space available"; - // } - // langCode = space.firstLanguageSettings?.targetLanguage; - // if (langCode == null) { - // throw "No target language available"; - // } - - // eventFutures = selected?.type == AnalyticsEntryType.student - // ? studentConstructs(selected!.id, langCode) - // : spaceMemberConstructs(space, langCode); - // } else if (defaultSelected.type == AnalyticsEntryType.student) { - // // in this case, we're on an individual's own analytics page - - // if (selected?.type == AnalyticsEntryType.space || - // selected?.type == AnalyticsEntryType.student) { - // langCode = _pangeaController.languageController - // .activeL2Code(roomID: selected!.id)!; - // eventFutures = myConstructs(langCode); - // } else { - // if (_pangeaController.languageController.userL2 == null) { - // throw "userL2 missing in constuctEventsByAnalyticsSelected"; - // } - // langCode = _pangeaController.languageController.userL2!.langCode; - // eventFutures = myConstructs(langCode); - // } - // } else { - // throw "invalid defaultSelected.type - ${defaultSelected.type}"; - // } - - // final List events = (await eventFutures) - // .where( - // (element) => element.content.type == constructType, - // ) - // .toList(); - - // final List chatIdsToFilterBy = []; - // if (selected?.type == AnalyticsEntryType.room) { - // chatIdsToFilterBy.add(selected!.id); - // } else if (selected?.type == AnalyticsEntryType.privateChats) { - // chatIdsToFilterBy.addAll( - // _pangeaController.matrixState.client - // .getRoomById(defaultSelected.id) - // ?.childrenAndGrandChildrenDirectChatIds ?? - // [], - // ); - // } else if (defaultSelected.type == AnalyticsEntryType.space) { - // chatIdsToFilterBy.addAll( - // _pangeaController.matrixState.client - // .getRoomById(defaultSelected.id) - // ?.childrenAndGrandChildren - // .where((e) => e.roomId != null) - // .map((e) => e.roomId!) ?? - // [], - // ); - // } - // if (chatIdsToFilterBy.isNotEmpty) { - // for (final event in events) { - // event.content.uses - // .removeWhere((u) => !chatIdsToFilterBy.contains(u.chatId)); - // } - // events.removeWhere((e) => e.content.uses.isEmpty); - // } - - // return events; - // } - - // Future vocabHeadwordsWithTotals( - // String langCode, - // List vocab, [ - // String? chatId, - // ]) async { - // final VocabHeadwords vocabHeadwords = - // await VocabHeadwords.getHeadwords(langCode); - // for (final vocabList in vocabHeadwords.lists) { - // for (final vocabEvent in vocab) { - // vocabList.addVocabUse( - // vocabEvent.content.lemma, - // vocabEvent.content.uses, - // ); - // } - // } - // return vocabHeadwords; - // } - - /// in student analytics page, the [defaultSelected] is the student - /// in class analytics page, the [defaultSelected] is the class - /// [defaultSelected] should never be a chat - /// the specific [selected] will be those items in the lists - chat, student or class - // Future vocabHeadsByAnalyticsSelected({ - // required AnalyticsSelected? selected, - // required AnalyticsSelected defaultSelected, - // }) async { - // Future> eventsFuture; - // String langCode; - - // if (defaultSelected.type == AnalyticsEntryType.space) { - // // as long as a student isn't selected, we want the vocab events for the whole class - // final Room? classRoom = - // _pangeaController.matrixState.client.getRoomById(defaultSelected.id); - // if (classRoom?.classSettings == null) { - // throw Exception("classRoom missing in spaceMemberVocab"); - // } - // langCode = classRoom!.classSettings!.targetLanguage; - // eventsFuture = selected?.type == AnalyticsEntryType.student - // ? studentConstructs(selected!.id, langCode) - // : spaceMemberVocab(defaultSelected.id); - // } else if (defaultSelected.type == AnalyticsEntryType.student) { - // // in this case, we're on an individual's own analytics page - // if (selected?.type == AnalyticsEntryType.space || - // selected?.type == AnalyticsEntryType.student) { - // langCode = _pangeaController.languageController - // .activeL2Code(roomID: selected!.id)!; - // eventsFuture = myConstructs(langCode); - // } else { - // if (_pangeaController.languageController.userL2 == null) { - // throw Exception("userL2 missing in vocabHeadsByAnalyticsSelected"); - // } - // langCode = _pangeaController.languageController.userL2!.langCode; - // eventsFuture = myConstructs(langCode); - // } - // } else { - // throw Exception("invalid defaultSelected.type - ${defaultSelected.type}"); - // } - - // return vocabHeadwordsWithTotals(langCode, await eventsFuture); - // } } -// this is a cache for the top level constructs data, before filtering -// (either all of a student's analytics, for my analytics, or all of a space's -// analytics, for class analytics) class ConstructCacheEntry { final TimeSpan timeSpan; final ConstructType type;