diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 43a4828ec..1e98bde02 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4668,7 +4668,7 @@ "numLemmasUsedCorrectly": "Number of lemmas used correctly at least once", "listLemmasUsedCorrectly": "List of lemmas used correctly at least once", "numLemmasUsedIncorrectly": "Number of lemmas used incorrectly at least once", - "listLemmasUsedIncorrectly": "List of lemmas used incorrectly at least once", + "listLemmasUsedIncorrectly": "Number of lemmas used correctly 0 times", "numLemmasSmallXP": "Number of lemmas with 0 - 30 XP", "numLemmasMediumXP": "Number of lemmas with 31 - 200 XP", "numLemmasLargeXP": "Number of lemmas with > 200 XP", @@ -4677,8 +4677,10 @@ "listLemmasLargeXP": "List of lemmas with > 200 XP", "numGrammarConcepts": "Number of grammar concepts", "listGrammarConcepts": "List of grammar concepts", - "listGrammarConceptsUsedCorrectly": "List of grammar concepts used correctly at least 80% of the time", - "listGrammarConceptsUsedIncorrectly": "List of grammar concepts used correctly less than 80% of the time", + "listGrammarConceptsUsedCorrectly": "List of grammar concepts used correctly in original messages at least 80% of the time", + "listGrammarConceptsUsedIncorrectly": "List of grammar concepts used correctly in original messages less than 80% of the time", + "listGrammarConceptsUseCorrectlySystemGenerated": "List of grammar concepts chosen correctly from system-generated suggestions at least 80% of the time", + "listGrammarConceptsUseIncorrectlySystemGenerated": "List of grammar concepts chosen correctly from system-generated suggestions less than 80% of the time", "incorrectGrammarConceptsUseCases": "Use cases of grammar concepts used incorrectly", "listGrammarConceptsSmallXP": "List of grammar concepts with 0 - 30 XP", "listGrammarConceptsMediumXP": "List of grammar concepts with 31 - 200 XP", @@ -4696,5 +4698,10 @@ "analyticsNotAvailable": "User analytics not available", "downloading": "Downloading...", "failedFetchUserAnalytics": "Failed to download user analytics", - "downloadComplete": "Download complete!" -} \ No newline at end of file + "downloadComplete": "Download complete!", + "dataAvailable": "Data availability", + "lemmasNeverUsedCorrectly": "Number of lemmas used correctly 0 times", + "available": "Available", + "unavailable": "Unavailable", + "accessingMemberAnalytics": "Accessing member analytics..." +} diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index f299cfc1f..27102cec5 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -20,7 +20,6 @@ import 'package:fluffychat/pages/chat/send_file_dialog.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/controllers/app_version_controller.dart'; -import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/utils/chat_list_handle_space_tap.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -1122,7 +1121,6 @@ class ChatListController extends State // #Pangea void _initPangeaControllers(Client client) { GoogleAnalytics.analyticsUserUpdate(client.userID); - client.migrateAnalyticsRooms(); MatrixState.pangeaController.initControllers(); if (mounted) { MatrixState.pangeaController.classController.joinCachedSpaceCode(context); diff --git a/lib/pangea/controllers/class_controller.dart b/lib/pangea/controllers/class_controller.dart index e7c4bd41c..f324d2eb2 100644 --- a/lib/pangea/controllers/class_controller.dart +++ b/lib/pangea/controllers/class_controller.dart @@ -12,7 +12,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/constants/local.key.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; -import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -36,17 +35,19 @@ class ClassController extends BaseController { /// to enable all other users to add child rooms to the space. void fixClassPowerLevels() { Future.wait( - _pangeaController.matrixState.client.spacesImTeaching.map( - (space) => space.setClassPowerLevels().catchError((err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "spaceID": space.id, - }, - ); - }), - ), + _pangeaController.matrixState.client.rooms + .where((room) => room.isSpace && room.isRoomAdmin) + .map( + (space) => space.setClassPowerLevels().catchError((err, s) { + ErrorHandler.logError( + e: err, + s: s, + data: { + "spaceID": space.id, + }, + ); + }), + ), ); } @@ -148,11 +149,6 @@ class ClassController extends BaseController { return; } - // when possible, add user's analytics room the to space they joined - room.addAnalyticsRoomsToSpace(); - - // and invite the space's teachers to the user's analytics rooms - room.inviteSpaceTeachersToAnalyticsRooms(); GoogleAnalytics.joinClass(classCode); if (room.client.getRoomById(room.id)?.membership != Membership.join) { diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index da0bf26aa..35c986e9e 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -28,6 +28,7 @@ class GetAnalyticsController { StreamSubscription? _analyticsUpdateSubscription; StreamController analyticsStream = StreamController.broadcast(); + StreamSubscription? _joinSpaceSubscription; ConstructListModel constructListModel = ConstructListModel(uses: []); Completer initCompleter = Completer(); @@ -72,10 +73,19 @@ class GetAnalyticsController { if (initCompleter.isCompleted) return; try { + _client.updateAnalyticsRoomVisibility(); + _client.addAnalyticsRoomsToSpaces(); + _analyticsUpdateSubscription ??= _pangeaController .putAnalytics.analyticsUpdateStream.stream .listen(_onAnalyticsUpdate); + // When a newly-joined space comes through in a sync + // update, add the analytics rooms to the space + _joinSpaceSubscription ??= _client.onSync.stream + .where(_client.isJoinSpaceSyncUpdate) + .listen((_) => _client.addAnalyticsRoomsToSpaces()); + await _pangeaController.putAnalytics.lastUpdatedCompleter.future; await _getConstructs(); constructListModel.updateConstructs([ @@ -99,6 +109,8 @@ class GetAnalyticsController { constructListModel.dispose(); _analyticsUpdateSubscription?.cancel(); _analyticsUpdateSubscription = null; + _joinSpaceSubscription?.cancel(); + _joinSpaceSubscription = null; initCompleter = Completer(); _cache.clear(); // perMessage.dispose(); diff --git a/lib/pangea/enum/analytics/analytics_summary_enum.dart b/lib/pangea/enum/analytics/analytics_summary_enum.dart index dd83b4e0e..dfb19742b 100644 --- a/lib/pangea/enum/analytics/analytics_summary_enum.dart +++ b/lib/pangea/enum/analytics/analytics_summary_enum.dart @@ -2,6 +2,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; enum AnalyticsSummaryEnum { username, + dataAvailable, level, totalXP, numLemmas, @@ -19,8 +20,10 @@ enum AnalyticsSummaryEnum { numMorphConstructs, listMorphConstructs, - listMorphConstructsUsedCorrectly, - listMorphConstructsUsedIncorrectly, + listMorphConstructsUsedCorrectlyOriginal, + listMorphConstructsUsedIncorrectlyOriginal, + listMorphConstructsUsedCorrectlySystem, + listMorphConstructsUsedIncorrectlySystem, // list morph 0 - 30 XP listMorphSmallXP, @@ -45,6 +48,8 @@ extension AnalyticsSummaryEnumExtension on AnalyticsSummaryEnum { switch (this) { case AnalyticsSummaryEnum.username: return l10n.username; + case AnalyticsSummaryEnum.dataAvailable: + return l10n.dataAvailable; case AnalyticsSummaryEnum.level: return l10n.level; case AnalyticsSummaryEnum.totalXP: @@ -65,10 +70,14 @@ extension AnalyticsSummaryEnumExtension on AnalyticsSummaryEnum { return l10n.numGrammarConcepts; case AnalyticsSummaryEnum.listMorphConstructs: return l10n.listGrammarConcepts; - case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectly: + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectlyOriginal: return l10n.listGrammarConceptsUsedCorrectly; - case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectly: + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectlyOriginal: return l10n.listGrammarConceptsUsedIncorrectly; + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectlySystem: + return l10n.listGrammarConceptsUseCorrectlySystemGenerated; + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectlySystem: + return l10n.listGrammarConceptsUseIncorrectlySystemGenerated; case AnalyticsSummaryEnum.listMorphSmallXP: return l10n.listGrammarConceptsSmallXP; case AnalyticsSummaryEnum.listMorphMediumXP: diff --git a/lib/pangea/extensions/client_extension/client_analytics_extension.dart b/lib/pangea/extensions/client_extension/client_analytics_extension.dart index 7226e439d..f6d651378 100644 --- a/lib/pangea/extensions/client_extension/client_analytics_extension.dart +++ b/lib/pangea/extensions/client_extension/client_analytics_extension.dart @@ -53,24 +53,15 @@ extension AnalyticsClientExtension on Client { }, name: "$userID $langCode Analytics", topic: "This room stores learning analytics for $userID.", - invite: [ - ...(await myTeachers).map((e) => e.id), - BotName.byEnvironment, - ], + preset: CreateRoomPreset.publicChat, + visibility: Visibility.private, ); if (getRoomById(roomID) == null) { // Wait for room actually appears in sync await waitForRoomInSync(roomID, join: true); } - final Room? analyticsRoom = getRoomById(roomID); - - // add this analytics room to all spaces so teachers can join them - // via the space hierarchy - analyticsRoom?.addAnalyticsRoomToSpaces(); - - // and invite all teachers to new analytics room - analyticsRoom?.inviteTeachersToAnalyticsRoom(); + _addAnalyticsRoomsToSpaces(); return getRoomById(roomID)!; } @@ -81,97 +72,96 @@ extension AnalyticsClientExtension on Client { ) .toList(); - // migration function to change analytics rooms' vsibility to public - // so they will appear in the space hierarchy + /// Update the visibility of all analytics rooms to private (do they don't show in search + /// results) and set the join rules to public (so they come through in space hierarchy response) Future _updateAnalyticsRoomVisibility() async { if (userID == null || userID == BotName.byEnvironment) return; + final Random random = Random(); + + for (final analyticsRoom in allMyAnalyticsRooms) { + if (userID == null) return; + final visibility = await getRoomVisibilityOnDirectory(analyticsRoom.id); + + // if making a call to the server (either to update visibility or join rules) + // add a delay at the end of this interaction to prevent overloading the server + int delay = 0; + if (visibility != Visibility.private || + analyticsRoom.joinRules != JoinRules.public) { + delay = random.nextInt(10); + } - final visibilityFutures = allMyAnalyticsRooms.map((room) async { - final visability = await getRoomVisibilityOnDirectory(room.id); - if (visability != Visibility.private) { + // don't show in search results + if (visibility != Visibility.private) { await setRoomVisibilityOnDirectory( - room.id, + analyticsRoom.id, visibility: Visibility.private, ); } - }).toList(); - final joinRulesFutures = allMyAnalyticsRooms.map((room) async { - if (room.joinRules != JoinRules.public) { - await room.setJoinRules(JoinRules.public); + // do show in space hierarchy + if (analyticsRoom.joinRules != JoinRules.public) { + await analyticsRoom.setJoinRules(JoinRules.public); } - }).toList(); - - await Future.wait( - visibilityFutures + joinRulesFutures, - ); - } - /// Add all the users' analytics room to all the spaces the user is studying in - /// so teachers can join them via space hierarchy. - /// Allows teachers to join analytics rooms without being invited. - void _addAnalyticsRoomsToAllSpaces() { - if (userID == null || userID == BotName.byEnvironment) return; - for (final Room room in allMyAnalyticsRooms) { - room.addAnalyticsRoomToSpaces(); + await Future.delayed(Duration(seconds: delay)); } } - /// Invite teachers to all my analytics room. - /// Handles case when students cannot add analytics room to space(s) - /// so teacher is still able to get analytics data for this student - void _inviteAllTeachersToAllAnalyticsRooms() { + /// Space admins join analytics rooms in spaces via the space hierarchy, + /// so other members of the space need to add their analytics rooms to the space. + Future _addAnalyticsRoomsToSpaces() async { if (userID == null || userID == BotName.byEnvironment) return; - for (final Room room in allMyAnalyticsRooms) { - room.inviteTeachersToAnalyticsRoom(); - } - } + final spaces = rooms.where((room) => room.isSpace).toList(); + + final Random random = Random(); + for (final space in spaces) { + if (userID == null) return; + final List roomsNotAdded = allMyAnalyticsRooms.where((room) { + return !space.spaceChildren.any((child) => child.roomId == room.id); + }).toList(); + + if (roomsNotAdded.isEmpty) continue; + + for (final analyticsRoom in roomsNotAdded) { + if (userID == null) return; + try { + await space.setSpaceChild(analyticsRoom.id); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "spaceID": space.id, + "analyticsRoomID": analyticsRoom.id, + "userID": userID, + }, + ); + } + } - // Join all analytics rooms in all spaces - // Allows teachers to join analytics rooms without being invited - Future _joinAnalyticsRoomsInAllSpaces() async { - for (final Room space in _spacesImTeaching) { - // Each call to joinAnalyticsRoomsInSpace calls getSpaceHierarchy, which has a - // strict rate limit. So we wait a second between each call to prevent a 429 error. - await Future.delayed( - const Duration(seconds: 1), - () => space.joinAnalyticsRoomsInSpace(), + // add a delay before checking the next space to prevent overloading the server + final delay = random.nextInt(10); + debugPrint( + "added ${roomsNotAdded.length} rooms to space ${space.id}, delay: $delay", ); + await Future.delayed(Duration(seconds: delay)); } } - /// Join invited analytics rooms. - /// Checks for invites to any student analytics rooms. - /// Handles case of analytics rooms that can't be added to some space(s). - void _joinInvitedAnalyticsRooms() { - Future.wait( - rooms - .where( - (room) => - room.membership == Membership.invite && room.isAnalyticsRoom, - ) - .map( - (room) => room.join().catchError((err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "roomID": room.id, - }, - ); - }), - ), - ); - } - - /// Helper function to join all relevant analytics rooms - /// and set up those rooms to be joined by other users. - void _migrateAnalyticsRooms() { - _updateAnalyticsRoomVisibility().then((_) { - _addAnalyticsRoomsToAllSpaces(); - _inviteAllTeachersToAllAnalyticsRooms(); - _joinInvitedAnalyticsRooms(); - _joinAnalyticsRoomsInAllSpaces(); - }); + /// Check if sync update includes newly joined room. Used by the + /// GetAnalyticsController to add analytics rooms to newly joined spaces. + bool _isJoinSpaceSyncUpdate(SyncUpdate update) { + if (update.rooms?.join == null) return false; + return update.rooms!.join!.values + .where( + (e) => + e.state != null && + e.state!.any( + (e) => + e.type == EventTypes.RoomCreate && + e.content['type'] == 'm.space', + ), + ) + .isNotEmpty; } } diff --git a/lib/pangea/extensions/client_extension/client_extension.dart b/lib/pangea/extensions/client_extension/client_extension.dart index d07c87919..2e513175b 100644 --- a/lib/pangea/extensions/client_extension/client_extension.dart +++ b/lib/pangea/extensions/client_extension/client_extension.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -9,13 +10,11 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; part "client_analytics_extension.dart"; part "general_info_extension.dart"; -part "space_extension.dart"; extension PangeaClient on Client { // analytics @@ -33,22 +32,18 @@ extension PangeaClient on Client { List get allMyAnalyticsRooms => _allMyAnalyticsRooms; + /// Update the visibility of all analytics rooms to private (do they don't show in search + /// results) and set the join rules to public (so they come through in space hierarchy response) Future updateAnalyticsRoomVisibility() async => - await _updateAnalyticsRoomVisibility(); + _updateAnalyticsRoomVisibility(); - /// Helper function to join all relevant analytics rooms - /// and set up those rooms to be joined by other users. - void migrateAnalyticsRooms() => _migrateAnalyticsRooms(); + /// Space admins join analytics rooms in spaces via the space hierarchy, + /// so other members of the space need to add their analytics rooms to the space. + Future addAnalyticsRoomsToSpaces() async => + _addAnalyticsRoomsToSpaces(); - // spaces - - List get spacesImTeaching => _spacesImTeaching; - - List get spacesImAStudentIn => _spacesImStudyingIn; - - List get spacesImIn => _spacesImIn; - - PangeaRoomRules? get lastUpdatedRoomRules => _lastUpdatedRoomRules; + bool isJoinSpaceSyncUpdate(SyncUpdate update) => + _isJoinSpaceSyncUpdate(update); // general_info diff --git a/lib/pangea/extensions/client_extension/general_info_extension.dart b/lib/pangea/extensions/client_extension/general_info_extension.dart index 777b21474..12431558d 100644 --- a/lib/pangea/extensions/client_extension/general_info_extension.dart +++ b/lib/pangea/extensions/client_extension/general_info_extension.dart @@ -3,7 +3,8 @@ part of "client_extension.dart"; extension GeneralInfoClientExtension on Client { Future> get _myTeachers async { final List teachers = []; - for (final classRoom in spacesImIn) { + final spaces = rooms.where((room) => room.isSpace); + for (final classRoom in spaces) { for (final teacher in await classRoom.teachers) { // If person requesting list of teachers is a teacher in another classroom, don't add them to the list if (!teachers.any((e) => e.id == teacher.id) && userID != teacher.id) { diff --git a/lib/pangea/extensions/client_extension/space_extension.dart b/lib/pangea/extensions/client_extension/space_extension.dart deleted file mode 100644 index 8d9182d85..000000000 --- a/lib/pangea/extensions/client_extension/space_extension.dart +++ /dev/null @@ -1,28 +0,0 @@ -part of "client_extension.dart"; - -extension SpaceClientExtension on Client { - List get _spacesImTeaching => - rooms.where((e) => e.isSpace && e.isRoomAdmin).toList(); - - List get _spacesImStudyingIn => - rooms.where((e) => e.isSpace && !e.isRoomAdmin).toList(); - - List get _spacesImIn => rooms.where((e) => e.isSpace).toList(); - - PangeaRoomRules? get _lastUpdatedRoomRules => _spacesImTeaching - .where((space) => space.rulesUpdatedAt != null) - .sorted( - (a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!), - ) - .firstOrNull - ?.pangeaRoomRules; - - // LanguageSettingsModel? get _lastUpdatedLanguageSettings => rooms - // .where((room) => room.isSpace && room.languageSettingsUpdatedAt != null) - // .sorted( - // (a, b) => b.languageSettingsUpdatedAt! - // .compareTo(a.languageSettingsUpdatedAt!), - // ) - // .firstOrNull - // ?.languageSettings; -} diff --git a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart index 7a962ff0a..6aa6dc793 100644 --- a/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/pangea_room_extension.dart @@ -19,14 +19,11 @@ import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:fluffychat/pangea/constants/bot_mode.dart'; import 'package:fluffychat/pangea/constants/class_code_constants.dart'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; -import 'package:fluffychat/pangea/constants/language_constants.dart'; import 'package:fluffychat/pangea/constants/model_keys.dart'; import 'package:fluffychat/pangea/constants/pangea_room_types.dart'; -import 'package:fluffychat/pangea/controllers/language_list_controller.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/bot_options_model.dart'; -import 'package:fluffychat/pangea/models/language_model.dart'; import 'package:fluffychat/pangea/models/space_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/bot_name.dart'; @@ -38,7 +35,6 @@ import '../../../config/app_config.dart'; import '../../constants/pangea_event_types.dart'; import '../../models/choreo_record.dart'; import '../../models/representation_content_model.dart'; -import '../client_extension/client_extension.dart'; part "room_analytics_extension.dart"; part "room_children_and_parents_extension.dart"; @@ -51,35 +47,7 @@ part "room_user_permissions_extension.dart"; extension PangeaRoom on Room { // analytics - /// Join analytics rooms in space. - /// Allows teachers to join analytics rooms without being invited. - Future joinAnalyticsRoomsInSpace() async => - await _joinAnalyticsRoomsInSpace(); - - Future addAnalyticsRoomToSpace(Room analyticsRoom) async => - await _addAnalyticsRoomToSpace(analyticsRoom); - - /// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces). - /// Enables teachers to join student analytics rooms via space hierarchy. - /// Will not always work, as there may be spaces where students don't have permission to add chats, - /// but allows teachers to join analytics rooms without being invited. - void addAnalyticsRoomToSpaces() => _addAnalyticsRoomToSpaces(); - - /// Add all the user's analytics rooms to 1 space. - void addAnalyticsRoomsToSpace() => _addAnalyticsRoomsToSpace(); - - /// Invite teachers of 1 space to 1 analytics room - Future inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async => - await _inviteSpaceTeachersToAnalyticsRoom(analyticsRoom); - - /// Invite all the user's teachers to 1 analytics room. - /// Handles case when students cannot add analytics room to space - /// so teacher is still able to get analytics data for this student. - void inviteTeachersToAnalyticsRoom() => _inviteTeachersToAnalyticsRoom(); - - /// Invite teachers of 1 space to all users' analytics rooms - void inviteSpaceTeachersToAnalyticsRooms() => - _inviteSpaceTeachersToAnalyticsRooms(); + Future joinAnalyticsRooms() async => await _joinAnalyticsRooms(); Future analyticsLastUpdated(String userId) async { return await _analyticsLastUpdated(userId); @@ -165,9 +133,6 @@ extension PangeaRoom on Room { Event? get pangeaRoomRulesStateEvent => _pangeaRoomRulesStateEvent; - Future> targetLanguages() async => - await _targetLanguages(); - // events Future leaveIfFull() async => await _leaveIfFull(); diff --git a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart index fd0c6dd06..ee2da394a 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -1,156 +1,77 @@ part of "pangea_room_extension.dart"; extension AnalyticsRoomExtension on Room { - /// Join analytics rooms in space. - /// Allows teachers to join analytics rooms without being invited. - Future _joinAnalyticsRoomsInSpace() async { - try { - if (!isSpace) { - debugger(when: kDebugMode); - return; - } + Future> _getFullSpaceHierarchy() async { + final resp = await client.getSpaceHierarchy( + id, + limit: 100, + maxDepth: 1, + ); - if (client.userID == null || !isRoomAdmin) return; - final spaceHierarchy = await client.getSpaceHierarchy( + final List rooms = resp.rooms; + String? nextBatch = resp.nextBatch; + int tries = 0; + + while (nextBatch != null && tries <= 5) { + final nextResp = await client.getSpaceHierarchy( id, + from: nextBatch, + limit: 100, maxDepth: 1, ); - - final List analyticsRoomIds = spaceHierarchy.rooms - .where((r) => r.roomType == PangeaRoomTypes.analytics) - .map((r) => r.roomId) - .toList(); - - await Future.wait( - analyticsRoomIds.map( - (roomID) => joinSpaceChild(roomID).catchError((err, s) { - debugPrint("Failed to join analytics room $roomID in space $id"); - ErrorHandler.logError( - e: err, - m: "Failed to join analytics room $roomID in space $id", - s: s, - data: { - "roomID": roomID, - "spaceID": id, - }, - ); - }), - ), - ); - } catch (err, s) { - ErrorHandler.logError( - e: err, - s: s, - data: { - "spaceID": id, - }, - ); - return; - } - } - - // add 1 analytics room to 1 space - Future _addAnalyticsRoomToSpace(Room analyticsRoom) async { - if (!isSpace) { - debugPrint("addAnalyticsRoomToSpace called on non-space room"); - Sentry.addBreadcrumb( - Breadcrumb( - message: "addAnalyticsRoomToSpace called on non-space room", - ), - ); - return Future.value(); + rooms.addAll(nextResp.rooms); + nextBatch = nextResp.nextBatch; + tries++; } - // Checks that user has permission to add child to space - if (!canSendEvent(EventTypes.SpaceChild)) return; - if (spaceChildren.any((sc) => sc.roomId == analyticsRoom.id)) return; - - try { - await setSpaceChild(analyticsRoom.id); - } catch (err) { - debugPrint( - "Failed to add analytics room ${analyticsRoom.id} for student to space $id", - ); - Sentry.addBreadcrumb( - Breadcrumb( - message: "Failed to add analytics room to space $id", - ), - ); - } + return rooms; } - /// Add analytics room to all spaces the user is a student in (1 analytics room to all spaces). - /// Enables teachers to join student analytics rooms via space hierarchy. - /// Will not always work, as there may be spaces where students don't have permission to add chats, - /// but allows teachers to join analytics rooms without being invited. - void _addAnalyticsRoomToSpaces() { - if (!isAnalyticsRoomOfUser(client.userID!)) return; - Future.wait( - client.spacesImAStudentIn - .where((space) => !space.spaceChildren.any((sc) => sc.roomId == id)) - .map((space) => space.addAnalyticsRoomToSpace(this)), - ); - } - - /// Add all the user's analytics rooms to 1 space. - void _addAnalyticsRoomsToSpace() { - Future.wait( - client.allMyAnalyticsRooms.map((room) => addAnalyticsRoomToSpace(room)), - ); - } - - /// Invite teachers of 1 space to 1 analytics room - Future _inviteSpaceTeachersToAnalyticsRoom(Room analyticsRoom) async { - if (!isSpace) return; - if (!analyticsRoom.participantListComplete) { - await analyticsRoom.requestParticipants(); - } + Future _joinAnalyticsRooms() async { + final List rooms = await _getFullSpaceHierarchy(); + + final unjoinedAnalyticsRooms = rooms.where( + (room) { + if (room.roomType != PangeaRoomTypes.analytics) return false; + final matchingRoom = client.rooms.firstWhereOrNull( + (r) => r.id == room.roomId, + ); + return matchingRoom == null || + matchingRoom.membership != Membership.join; + }, + ).toList(); + + const batchSize = 5; + int batchNum = 0; + while (batchSize * batchNum < unjoinedAnalyticsRooms.length) { + final batch = + unjoinedAnalyticsRooms.sublist(batchSize * batchNum).take(batchSize); + + batchNum++; + for (final analyticsRoom in batch) { + try { + final syncFuture = + client.waitForRoomInSync(analyticsRoom.roomId, join: true); + await client.joinRoom(analyticsRoom.roomId); + await syncFuture; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "spaceID": id, + "roomID": analyticsRoom.roomId, + }, + ); + } + } - final List participants = analyticsRoom.getParticipants(); - final List uninvitedTeachers = (await teachers) - .where((teacher) => !participants.contains(teacher)) - .toList(); - - if (analyticsRoom.canSendEvent(EventTypes.RoomMember)) { - Future.wait( - uninvitedTeachers.map( - (teacher) => analyticsRoom.invite(teacher.id).catchError((err, s) { - ErrorHandler.logError( - e: err, - m: "Failed to invite teacher ${teacher.id} to analytics room ${analyticsRoom.id}", - s: s, - data: { - "teacherID": teacher.id, - "analyticsRoomID": analyticsRoom.id, - }, - ); - }), - ), - ); + if (batchSize * batchNum < unjoinedAnalyticsRooms.length) { + await Future.delayed(const Duration(milliseconds: 7500)); + } } } - /// Invite all the user's teachers to 1 analytics room. - /// Handles case when students cannot add analytics room to space - /// so teacher is still able to get analytics data for this student. - void _inviteTeachersToAnalyticsRoom() { - if (client.userID == null || !isAnalyticsRoomOfUser(client.userID!)) return; - Future.wait( - client.spacesImAStudentIn.map( - (space) => inviteSpaceTeachersToAnalyticsRoom(this), - ), - ); - } - - /// Invite teachers of 1 space to all users' analytics rooms - void _inviteSpaceTeachersToAnalyticsRooms() { - Future.wait( - client.allMyAnalyticsRooms.map( - (room) => inviteSpaceTeachersToAnalyticsRoom(room), - ), - ); - } - Future _analyticsLastUpdated(String userId) async { final List events = await getRoomAnalyticsEvents(count: 1, userID: userId); diff --git a/lib/pangea/extensions/pangea_room_extension/room_space_settings_extension.dart b/lib/pangea/extensions/pangea_room_extension/room_space_settings_extension.dart index 1fb181059..d2c9b7946 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_space_settings_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_space_settings_extension.dart @@ -110,35 +110,6 @@ extension SpaceRoomExtension on Room { return null; } - Future> _targetLanguages() async { - await requestParticipants(); - final students = _students; - - final Map langCounts = {}; - final List allRooms = client.rooms; - for (final User student in students) { - for (final Room room in allRooms) { - if (!room.isAnalyticsRoomOfUser(student.id)) continue; - final String? langCode = room.madeForLang; - if (langCode == null || - langCode.isEmpty || - langCode == LanguageKeys.unknownLanguage) { - continue; - } - final LanguageModel? lang = PangeaLanguage.byLangCode(langCode); - if (lang == null) continue; - langCounts[lang] ??= 0; - langCounts[lang] = langCounts[lang]! + 1; - } - } - // get a list of language models, sorted - // by the number of students who are learning that language - return langCounts.entries.map((entry) => entry.key).toList() - ..sort( - (a, b) => langCounts[b]!.compareTo(langCounts[a]!), - ); - } - // DateTime? get _languageSettingsUpdatedAt { // if (!isSpace) return null; // return languageSettingsStateEvent?.originServerTs ?? creationTime; diff --git a/lib/pangea/models/analytics/analytics_summary_model.dart b/lib/pangea/models/analytics/analytics_summary_model.dart index cd0bb500b..7c58c363a 100644 --- a/lib/pangea/models/analytics/analytics_summary_model.dart +++ b/lib/pangea/models/analytics/analytics_summary_model.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + import 'package:fluffychat/pangea/enum/analytics/analytics_summary_enum.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; @@ -8,104 +10,150 @@ import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; class AnalyticsSummaryModel { String username; - int level; - int totalXP; + bool dataAvailable; + int? level; + int? totalXP; - int numLemmas; - int numLemmasUsedCorrectly; - int numLemmasUsedIncorrectly; + int? numLemmas; + int? numLemmasUsedCorrectly; + int? numLemmasUsedIncorrectly; /// 0 - 30 XP - int numLemmasSmallXP; + int? numLemmasSmallXP; /// 31 - 200 XP - int numLemmasMediumXP; + int? numLemmasMediumXP; /// > 200 XP - int numLemmasLargeXP; + int? numLemmasLargeXP; - int numMorphConstructs; - List listMorphConstructs; - List listMorphConstructsUsedCorrectly; - List listMorphConstructsUsedIncorrectly; + int? numMorphConstructs; + List? listMorphConstructs; + List? listMorphConstructsUsedCorrectlyOriginal; + List? listMorphConstructsUsedIncorrectlyOriginal; + List? listMorphConstructsUsedCorrectlySystem; + List? listMorphConstructsUsedIncorrectlySystem; // list morph 0 - 30 XP - List listMorphSmallXP; + List? listMorphSmallXP; // list morph 31 - 200 XP - List listMorphMediumXP; + List? listMorphMediumXP; // list morph 200 - 500 XP - List listMorphLargeXP; + List? listMorphLargeXP; // list morph > 500 XP - List listMorphHugeXP; + List? listMorphHugeXP; - int numMessagesSent; - int numWordsTyped; - int numChoicesCorrect; - int numChoicesIncorrect; + int? numMessagesSent; + int? numWordsTyped; + int? numChoicesCorrect; + int? numChoicesIncorrect; AnalyticsSummaryModel({ required this.username, - required this.level, - required this.totalXP, - required this.numLemmas, - required this.numLemmasUsedCorrectly, - required this.numLemmasUsedIncorrectly, - required this.numLemmasSmallXP, - required this.numLemmasMediumXP, - required this.numLemmasLargeXP, - required this.numMorphConstructs, - required this.listMorphConstructs, - required this.listMorphConstructsUsedCorrectly, - required this.listMorphConstructsUsedIncorrectly, - required this.listMorphSmallXP, - required this.listMorphMediumXP, - required this.listMorphLargeXP, - required this.listMorphHugeXP, - required this.numMessagesSent, - required this.numWordsTyped, - required this.numChoicesCorrect, - required this.numChoicesIncorrect, + required this.dataAvailable, + this.level, + this.totalXP, + this.numLemmas, + this.numLemmasUsedCorrectly, + this.numLemmasUsedIncorrectly, + this.numLemmasSmallXP, + this.numLemmasMediumXP, + this.numLemmasLargeXP, + this.numMorphConstructs, + this.listMorphConstructs, + this.listMorphConstructsUsedCorrectlyOriginal, + this.listMorphConstructsUsedIncorrectlyOriginal, + this.listMorphConstructsUsedCorrectlySystem, + this.listMorphConstructsUsedIncorrectlySystem, + this.listMorphSmallXP, + this.listMorphMediumXP, + this.listMorphLargeXP, + this.listMorphHugeXP, + this.numMessagesSent, + this.numWordsTyped, + this.numChoicesCorrect, + this.numChoicesIncorrect, }); + static AnalyticsSummaryModel emptyModel(String userID) { + return AnalyticsSummaryModel( + username: userID, + dataAvailable: false, + ); + } + static AnalyticsSummaryModel fromConstructListModel( - ConstructListModel model, String userID, + ConstructListModel? model, String Function(ConstructUses) getCopy, BuildContext context, ) { - final vocabLemmas = LemmasToUsesWrapper( - model.lemmasToUses(type: ConstructTypeEnum.vocab), - ); - final morphLemmas = LemmasToUsesWrapper( - model.lemmasToUses(type: ConstructTypeEnum.morph), - ); + final vocabLemmas = model != null + ? LemmasToUsesWrapper( + model.lemmasToUses(type: ConstructTypeEnum.vocab), + ) + : null; + final morphLemmas = model != null + ? LemmasToUsesWrapper( + model.lemmasToUses(type: ConstructTypeEnum.morph), + ) + : null; - final morphLemmasPercentCorrect = morphLemmas.lemmasByPercent( - percent: 0.8, - getCopy: getCopy, - ); + final List correctOriginalUseLemmas = []; + final List correctSystemUseLemmas = []; + final List incorrectOriginalUseLemmas = []; + final List incorrectSystemUseLemmas = []; + + if (morphLemmas != null) { + final originalWrittenUses = morphLemmas.lemmasByPercent( + filter: (use) => + use.useType == ConstructUseTypeEnum.wa || + use.useType == ConstructUseTypeEnum.ga, + percent: 0.8, + ); + + correctSystemUseLemmas.addAll(originalWrittenUses.over); + incorrectSystemUseLemmas.addAll(originalWrittenUses.under); + + final systemGeneratedUses = morphLemmas.lemmasByPercent( + filter: (use) => + use.useType != ConstructUseTypeEnum.wa && + use.useType != ConstructUseTypeEnum.ga && + use.useType != ConstructUseTypeEnum.unk && + use.pointValue != 0, + percent: 0.8, + ); + + correctSystemUseLemmas.addAll(systemGeneratedUses.over); + incorrectSystemUseLemmas.addAll(systemGeneratedUses.under); + } + + final vocabLemmasCorrect = vocabLemmas?.lemmasByCorrectUse(); - final vocabLemmasCorrect = vocabLemmas.lemmasByCorrectUse(getCopy: getCopy); - - int numWordsTyped = 0; - int numChoicesCorrect = 0; - int numChoicesIncorrect = 0; - for (final use in model.uses) { - if (use.useType.summaryEnumType == AnalyticsSummaryEnum.numWordsTyped) { - numWordsTyped++; - } else if (use.useType.summaryEnumType == - AnalyticsSummaryEnum.numChoicesCorrect) { - numChoicesCorrect++; - } else if (use.useType.summaryEnumType == - AnalyticsSummaryEnum.numChoicesIncorrect) { - numChoicesIncorrect++; + int? numWordsTyped; + int? numChoicesCorrect; + int? numChoicesIncorrect; + if (model != null) { + numWordsTyped = 0; + numChoicesCorrect = 0; + numChoicesIncorrect = 0; + for (final use in model.uses) { + if (use.useType.summaryEnumType == AnalyticsSummaryEnum.numWordsTyped) { + numWordsTyped = numWordsTyped! + 1; + } else if (use.useType.summaryEnumType == + AnalyticsSummaryEnum.numChoicesCorrect) { + numChoicesCorrect = numChoicesCorrect! + 1; + } else if (use.useType.summaryEnumType == + AnalyticsSummaryEnum.numChoicesIncorrect) { + numChoicesIncorrect = numChoicesIncorrect! + 1; + } } } - final numMessageSent = model.uses + final numMessageSent = model?.uses .where((use) => use.useType.sentByUser) .map((use) => use.metadata.eventId) .toSet() @@ -113,37 +161,41 @@ class AnalyticsSummaryModel { return AnalyticsSummaryModel( username: userID, - level: model.level, - totalXP: model.totalXP, - numLemmas: model.vocabLemmas, - numLemmasUsedCorrectly: vocabLemmasCorrect.over.length, - numLemmasUsedIncorrectly: vocabLemmasCorrect.under.length, - numLemmasSmallXP: vocabLemmas.thresholdedLemmas(start: 0, end: 30).length, + dataAvailable: model != null, + level: model?.level, + totalXP: model?.totalXP, + numLemmas: model?.vocabLemmas, + numLemmasUsedCorrectly: vocabLemmasCorrect?.over.length, + numLemmasUsedIncorrectly: vocabLemmasCorrect?.under.length, + numLemmasSmallXP: + vocabLemmas?.thresholdedLemmas(start: 0, end: 30).length, numLemmasMediumXP: - vocabLemmas.thresholdedLemmas(start: 31, end: 200).length, - numLemmasLargeXP: vocabLemmas.thresholdedLemmas(start: 201).length, - numMorphConstructs: model.grammarLemmas, - listMorphConstructs: morphLemmas.lemmasToUses.entries + vocabLemmas?.thresholdedLemmas(start: 31, end: 200).length, + numLemmasLargeXP: vocabLemmas?.thresholdedLemmas(start: 201).length, + numMorphConstructs: model?.grammarLemmas, + listMorphConstructs: morphLemmas?.lemmasToUses.entries .map((entry) => getCopy(entry.value.first)) .toList(), - listMorphConstructsUsedCorrectly: morphLemmasPercentCorrect.over, - listMorphConstructsUsedIncorrectly: morphLemmasPercentCorrect.under, - listMorphSmallXP: morphLemmas.thresholdedLemmas( + listMorphConstructsUsedCorrectlyOriginal: correctOriginalUseLemmas, + listMorphConstructsUsedIncorrectlyOriginal: incorrectOriginalUseLemmas, + listMorphConstructsUsedCorrectlySystem: correctSystemUseLemmas, + listMorphConstructsUsedIncorrectlySystem: incorrectSystemUseLemmas, + listMorphSmallXP: morphLemmas?.thresholdedLemmas( start: 0, end: 30, getCopy: getCopy, ), - listMorphMediumXP: morphLemmas.thresholdedLemmas( + listMorphMediumXP: morphLemmas?.thresholdedLemmas( start: 31, end: 200, getCopy: getCopy, ), - listMorphLargeXP: morphLemmas.thresholdedLemmas( + listMorphLargeXP: morphLemmas?.thresholdedLemmas( start: 201, end: 500, getCopy: getCopy, ), - listMorphHugeXP: morphLemmas.thresholdedLemmas( + listMorphHugeXP: morphLemmas?.thresholdedLemmas( start: 501, getCopy: getCopy, ), @@ -154,10 +206,14 @@ class AnalyticsSummaryModel { ); } - dynamic getValue(AnalyticsSummaryEnum key) { + dynamic getValue(AnalyticsSummaryEnum key, BuildContext context) { switch (key) { case AnalyticsSummaryEnum.username: return username; + case AnalyticsSummaryEnum.dataAvailable: + return dataAvailable + ? L10n.of(context).available + : L10n.of(context).unavailable; case AnalyticsSummaryEnum.level: return level; case AnalyticsSummaryEnum.totalXP: @@ -178,10 +234,14 @@ class AnalyticsSummaryModel { return numMorphConstructs; case AnalyticsSummaryEnum.listMorphConstructs: return listMorphConstructs; - case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectly: - return listMorphConstructsUsedCorrectly; - case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectly: - return listMorphConstructsUsedIncorrectly; + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectlyOriginal: + return listMorphConstructsUsedCorrectlyOriginal; + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectlyOriginal: + return listMorphConstructsUsedIncorrectlyOriginal; + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectlySystem: + return listMorphConstructsUsedCorrectlySystem; + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectlySystem: + return listMorphConstructsUsedIncorrectlySystem; case AnalyticsSummaryEnum.listMorphSmallXP: return listMorphSmallXP; case AnalyticsSummaryEnum.listMorphMediumXP: @@ -214,8 +274,10 @@ class AnalyticsSummaryModel { 'numLemmasLargeXP': numLemmasLargeXP, 'numMorphConstructs': numMorphConstructs, 'listMorphConstructs': listMorphConstructs, - 'listMorphConstructsUsedCorrectly': listMorphConstructsUsedCorrectly, - 'listMorphConstructsUsedIncorrectly': listMorphConstructsUsedIncorrectly, + 'listMorphConstructsUsedCorrectly': + listMorphConstructsUsedCorrectlyOriginal, + 'listMorphConstructsUsedIncorrectly': + listMorphConstructsUsedIncorrectlyOriginal, 'listMorphSmallXP': listMorphSmallXP, 'listMorphMediumXP': listMorphMediumXP, 'listMorphLargeXP': listMorphLargeXP, diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 8bde61481..f5612c49f 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -247,8 +247,54 @@ class LemmasToUsesWrapper { LemmasToUsesWrapper(this.lemmasToUses); + Map> lemmasToFilteredUses( + bool Function(OneConstructUse) filter, + ) { + final Map> lemmasToOneConstructUses = {}; + for (final entry in lemmasToUses.entries) { + final lemma = entry.key; + final uses = entry.value; + lemmasToOneConstructUses[lemma] = + uses.expand((use) => use.uses).toList().where(filter).toList(); + } + return lemmasToOneConstructUses; + } + + LemmasOverUnderList lemmasByPercent({ + required bool Function(OneConstructUse) filter, + required double percent, + }) { + final List correctUseLemmas = []; + final List incorrectUseLemmas = []; + + final uses = lemmasToFilteredUses(filter); + for (final entry in uses.entries) { + final List correctUses = []; + final List incorrectUses = []; + + final lemma = entry.key; + final uses = entry.value.toList(); + + for (final use in uses) { + use.pointValue > 0 ? correctUses.add(use) : incorrectUses.add(use); + } + + final totalUses = correctUses.length + incorrectUses.length; + final percent = totalUses == 0 ? 0 : correctUses.length / totalUses; + + percent > 0.8 + ? correctUseLemmas.add(lemma) + : incorrectUseLemmas.add(lemma); + } + + return LemmasOverUnderList( + over: correctUseLemmas, + under: incorrectUseLemmas, + ); + } + /// Return an object containing two lists, one of lemmas with - /// any correct uses and one of lemmas with any incorrect uses + /// any correct uses and one of lemmas no correct uses LemmasOverUnderList lemmasByCorrectUse({ String Function(ConstructUses)? getCopy, }) { @@ -260,46 +306,13 @@ class LemmasToUsesWrapper { final copy = getCopy?.call(constructUses.first) ?? lemma; if (constructUses.any((use) => use.hasCorrectUse)) { correctLemmas.add(copy); - } - if (constructUses.any((use) => use.hasIncorrectUse)) { + } else { incorrectLemmas.add(copy); } } return LemmasOverUnderList(over: correctLemmas, under: incorrectLemmas); } - /// Return an object containing two lists, one of lemmas with percent used - /// correctly > percent and one of lemmas with percent used correctly < percent - LemmasOverUnderList lemmasByPercent({ - double percent = 0.8, - String Function(ConstructUses)? getCopy, - }) { - final List overLemmas = []; - final List underLemmas = []; - for (final entry in lemmasToUses.entries) { - final lemma = entry.key; - final constructUses = entry.value; - final uses = constructUses.map((u) => u.uses).expand((e) => e).toList(); - - int correct = 0; - int incorrect = 0; - for (final use in uses) { - if (use.pointValue > 0) { - correct++; - } else if (use.pointValue < 0) { - incorrect++; - } - } - - if (correct + incorrect == 0) continue; - - final copy = getCopy?.call(constructUses.first) ?? lemma; - final percent = correct / (correct + incorrect); - percent >= percent ? overLemmas.add(copy) : underLemmas.add(copy); - } - return LemmasOverUnderList(over: overLemmas, under: underLemmas); - } - int totalXP(String lemma) { final uses = lemmasToUses[lemma]; if (uses == null) return 0; diff --git a/lib/pangea/pages/class_invitation_selection/class_invitation_selection.dart b/lib/pangea/pages/class_invitation_selection/class_invitation_selection.dart deleted file mode 100644 index ddce06930..000000000 --- a/lib/pangea/pages/class_invitation_selection/class_invitation_selection.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:adaptive_dialog/adaptive_dialog.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/matrix.dart'; - -class ClassInvitationSelection extends StatefulWidget { - const ClassInvitationSelection({super.key}); - - @override - ClassInvitationSelectionController createState() => - ClassInvitationSelectionController(); -} - -class ClassInvitationSelectionController - extends State { - TextEditingController controller = TextEditingController(); - late String currentSearchTerm; - bool loading = true; - List allClassParticipants = []; - List allChatParticipants = []; - List classParticipantsFilteredByChat = []; - - ///Class participants filtered by chat participants and any search query - List foundProfiles = []; - - Timer? coolDown; - - String? get roomId => GoRouterState.of(context).pathParameters['roomid']; - - StreamSubscription? _spaceSubscription; - - void inviteAction(BuildContext context, String id) async { - final room = Matrix.of(context).client.getRoomById(roomId!)!; - if (OkCancelResult.ok != - await showOkCancelAlertDialog( - context: context, - title: L10n.of(context).inviteContactToGroup(room.name), - okLabel: L10n.of(context).yes, - cancelLabel: L10n.of(context).cancel, - )) { - return; - } - final success = await showFutureLoadingDialog( - context: context, - future: () => room.invite(id), - ); - if (success.error == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - // #Pangea - // content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup), - content: Text(L10n.of(context).contactHasBeenInvitedToTheChat), - // Pangea# - ), - ); - } - } - - void setDisplayListWithCoolDown(String text) { - coolDown?.cancel(); - coolDown = Timer( - const Duration(milliseconds: 0), - () => _setfoundProfiles(context, text), - ); - } - - void _setfoundProfiles(BuildContext context, String text) { - coolDown?.cancel(); - // debugger(when: kDebugMode); - allClassParticipants = getClassParticipants(context); - allChatParticipants = getChatParticipants(context); - classParticipantsFilteredByChat = getClassParticipantsFilteredByChat(); - - currentSearchTerm = text; - - foundProfiles = currentSearchTerm.isNotEmpty - ? classParticipantsFilteredByChat - .where( - (user) => - user.displayName?.contains(text) ?? - false || user.id.contains(text), - ) - .toList() - : classParticipantsFilteredByChat; - - setState(() => loading = false); - } - - Room? _getParentClass(BuildContext context) => Matrix.of(context) - .client - .rooms - .where( - (r) => r.isSpace, - ) - .firstWhereOrNull( - (space) => space.spaceChildren.any( - (ithroom) => ithroom.roomId == roomId, - ), - ); - - List getClassParticipants(BuildContext context) { - final Room? parent = _getParentClass(context); - if (parent == null) return []; - - final List classParticipants = - parent.getParticipants([Membership.join]); - return classParticipants; - } - - List getChatParticipants(BuildContext context) => Matrix.of(context) - .client - .getRoomById(roomId!)! - .getParticipants([Membership.join, Membership.invite]).toList(); - - List getClassParticipantsFilteredByChat() => allClassParticipants - .where( - (profile) => - allChatParticipants.indexWhere((u) => u.id == profile.id) == -1, - ) - .toList(); - - @override - void initState() { - Future.delayed(Duration.zero, () async { - final Room? classParent = _getParentClass(context); - await classParent - ?.requestParticipants([Membership.join, Membership.invite]); - _setfoundProfiles(context, ""); - _spaceSubscription = Matrix.of(context) - .client - .onSync - .stream - .where( - (event) => - event.rooms?.join?.keys - .any((ithRoomId) => ithRoomId == classParent?.id) ?? - false, - ) - .listen( - (SyncUpdate syncUpdate) async { - debugPrint("updating lists"); - await classParent - ?.requestParticipants([Membership.join, Membership.invite]); - setState(() {}); - }, - ); - }); - super.initState(); - } - - @override - void dispose() { - _spaceSubscription?.cancel(); - super.dispose(); - } - - @override - // Widget build(BuildContext context) => InvitationSelectionView(this); - Widget build(BuildContext context) => const SizedBox(); -} diff --git a/lib/pangea/utils/chat_list_handle_space_tap.dart b/lib/pangea/utils/chat_list_handle_space_tap.dart index f66be7496..f0c56376e 100644 --- a/lib/pangea/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/utils/chat_list_handle_space_tap.dart @@ -70,9 +70,6 @@ void chatListHandleSpaceTap( if (await space.leaveIfFull()) { throw L10n.of(context).roomFull; } - if (space.isSpace) { - space.joinAnalyticsRoomsInSpace(); - } setActiveSpaceAndCloseChat(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pangea/utils/report_message.dart b/lib/pangea/utils/report_message.dart index 7c1178476..094a4103e 100644 --- a/lib/pangea/utils/report_message.dart +++ b/lib/pangea/utils/report_message.dart @@ -103,8 +103,8 @@ Future> getReportTeachers( final List otherSpaces = Matrix.of(context) .client - .spacesImIn - .where((space) => !reportRoomParentSpaces.contains(space)) + .rooms + .where((room) => room.isSpace && !reportRoomParentSpaces.contains(room)) .toList(); for (final space in otherSpaces) { diff --git a/lib/pangea/widgets/download_analytics_dialog.dart b/lib/pangea/widgets/download_analytics_dialog.dart index 55d4feb4d..fa39c0956 100644 --- a/lib/pangea/widgets/download_analytics_dialog.dart +++ b/lib/pangea/widgets/download_analytics_dialog.dart @@ -33,21 +33,30 @@ class DownloadAnalyticsDialog extends StatefulWidget { class DownloadAnalyticsDialogState extends State { bool _initialized = false; - bool _loading = false; - bool _finishedDownload = false; + bool _downloaded = false; + bool _joiningRooms = false; + bool _downloading = false; + + bool get _loading => _joiningRooms || _downloading || !_initialized; + String? _error; - Map _downloadStatues = {}; + Map _downloadStatuses = {}; @override void initState() { super.initState(); - widget.space - .requestParticipants([Membership.join], false, true).whenComplete(() { - _resetDownloadStatuses(); - _initialized = true; - if (mounted) setState(() {}); - }).catchError((e, s) { + _initialize(); + } + + Future _initialize() async { + try { + await widget.space.requestParticipants( + [Membership.join], + false, + true, + ); + } catch (e, s) { ErrorHandler.logError( e: e, s: s, @@ -55,9 +64,26 @@ class DownloadAnalyticsDialogState extends State { "spaceID": widget.space.id, }, ); - if (mounted) setState(() => _error = e.toString()); - return []; - }); + } finally { + if (mounted) setState(() => _initialized = true); + } + } + + DownloadType _downloadType = DownloadType.csv; + + void _setDownloadType(DownloadType type) { + _clean(); + if (mounted) setState(() => _downloadType = type); + } + + void _clean() { + _error = null; + _joiningRooms = false; + _downloading = false; + _downloaded = false; + _downloadStatuses = Map.fromEntries( + _usersToDownload.map((user) => MapEntry(user.id, 0)), + ); } List get _usersToDownload => widget.space @@ -66,13 +92,20 @@ class DownloadAnalyticsDialogState extends State { .toList(); Color _downloadStatusColor(String userID) { - final status = _downloadStatues[userID]; + final status = _downloadStatuses[userID]; if (status == 1) return Colors.yellow; if (status == 2) return Colors.green; - if (status == -1) return Colors.red; + if ((status ?? 0) < 0) return Colors.red; return Colors.grey; } + String? get _statusText { + if (_joiningRooms) return L10n.of(context).accessingMemberAnalytics; + if (_downloading) return L10n.of(context).downloading; + if (_downloaded) return L10n.of(context).downloadComplete; + return null; + } + Room? _userAnalyticsRoom(String userID) { final rooms = widget.space.client.rooms; final l2 = MatrixState.pangeaController.languageController.userL2?.langCode; @@ -84,12 +117,27 @@ class DownloadAnalyticsDialogState extends State { Future _runDownload() async { try { - _loading = true; - _error = null; - _resetDownloadStatuses(); - if (mounted) setState(() {}); + if (!mounted) return; + setState(() { + _error = null; + _joiningRooms = true; + }); + + await widget.space.joinAnalyticsRooms(); + if (mounted) { + setState(() { + _joiningRooms = false; + _downloading = true; + }); + } + await _downloadSpaceAnalytics(); - if (mounted) setState(() => _finishedDownload = true); + if (mounted) { + setState(() { + _downloading = false; + _downloaded = true; + }); + } } catch (e, s) { ErrorHandler.logError( e: e, @@ -98,11 +146,10 @@ class DownloadAnalyticsDialogState extends State { "spaceID": widget.space.id, }, ); - _resetDownloadStatuses(); + + _clean(); _error = e.toString(); if (mounted) setState(() {}); - } finally { - if (mounted) setState(() => _loading = false); } } @@ -131,14 +178,17 @@ class DownloadAnalyticsDialogState extends State { Future _getUserAnalyticsModel(String userID) async { try { - setState(() => _downloadStatues[userID] = 1); final userAnalyticsRoom = _userAnalyticsRoom(userID); + _downloadStatuses[userID] = userAnalyticsRoom != null ? 1 : -1; + if (mounted) setState(() {}); + final constructEvents = await userAnalyticsRoom?.getAnalyticsEvents( userId: userID, ); + if (constructEvents == null) { - setState(() => _downloadStatues[userID] = 0); - return null; + if (mounted) setState(() => _downloadStatuses[userID] = -1); + return AnalyticsSummaryModel.emptyModel(userID); } final List uses = []; @@ -148,12 +198,12 @@ class DownloadAnalyticsDialogState extends State { final constructs = ConstructListModel(uses: uses); final summary = AnalyticsSummaryModel.fromConstructListModel( - constructs, userID, + constructs, getCopy, context, ); - setState(() => _downloadStatues[userID] = 2); + if (mounted) setState(() => _downloadStatuses[userID] = 2); return summary; } catch (e, s) { ErrorHandler.logError( @@ -164,7 +214,7 @@ class DownloadAnalyticsDialogState extends State { "userID": userID, }, ); - setState(() => _downloadStatues[userID] = -1); + if (mounted) setState(() => _downloadStatuses[userID] = -2); } return null; } @@ -175,7 +225,7 @@ class DownloadAnalyticsDialogState extends State { final List row = []; for (int i = 0; i < AnalyticsSummaryEnum.values.length; i++) { final key = AnalyticsSummaryEnum.values[i]; - final value = summary.getValue(key); + final value = summary.getValue(key, context); if (value is int) { row.add(IntCellValue(value)); } else if (value is String) { @@ -232,7 +282,8 @@ class DownloadAnalyticsDialogState extends State { final row = []; for (int i = 0; i < AnalyticsSummaryEnum.values.length; i++) { final key = AnalyticsSummaryEnum.values[i]; - final value = summary.getValue(key); + final value = summary.getValue(key, context); + if (value == null) continue; value is List ? row.add(value.join(", ")) : row.add(value); } rows.add(row); @@ -251,21 +302,6 @@ class DownloadAnalyticsDialogState extends State { use.lemma; } - DownloadType _downloadType = DownloadType.csv; - - void _setDownloadType(DownloadType type) { - _resetDownloadStatuses(); - setState(() => _downloadType = type); - } - - void _resetDownloadStatuses() { - _error = null; - _finishedDownload = false; - _downloadStatues = Map.fromEntries( - _usersToDownload.map((user) => MapEntry(user.id, 0)), - ); - } - @override Widget build(BuildContext context) { return Dialog( @@ -312,28 +348,26 @@ class DownloadAnalyticsDialogState extends State { itemCount: _usersToDownload.length, itemBuilder: (context, index) { final user = _usersToDownload[index]; - final analyticsAvailable = - _userAnalyticsRoom(user.id) != null; String tooltip = ""; - if (!analyticsAvailable) { + if (_downloadStatuses[user.id] == -1) { tooltip = L10n.of(context).analyticsNotAvailable; - } else if (_downloadStatues[user.id] == -1) { + } else if (_downloadStatuses[user.id] == -2) { tooltip = L10n.of(context).failedFetchUserAnalytics; } return Padding( padding: const EdgeInsets.all(4.0), - child: Opacity( - opacity: analyticsAvailable && - _downloadStatues[user.id] != -1 - ? 1 - : 0.5, + child: AnimatedOpacity( + duration: FluffyThemes.animationDuration, + opacity: + (_downloadStatuses[user.id] ?? 0) > 0 ? 1 : 0.5, child: Row( children: [ SizedBox( - width: 30, - child: !analyticsAvailable + width: 40, + height: 30, + child: (_downloadStatuses[user.id] ?? 0) < 0 ? const Icon( Icons.error_outline, size: 16, @@ -377,7 +411,7 @@ class DownloadAnalyticsDialogState extends State { padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0), child: OutlinedButton( onPressed: _loading || !_initialized ? null : _runDownload, - child: _initialized + child: _initialized && !_loading ? Text( _loading ? L10n.of(context).downloading @@ -385,17 +419,17 @@ class DownloadAnalyticsDialogState extends State { ) : const SizedBox( height: 10, - width: 10, - child: CircularProgressIndicator.adaptive(), + width: 100, + child: LinearProgressIndicator(), ), ), ), AnimatedSize( duration: FluffyThemes.animationDuration, - child: _finishedDownload + child: _statusText != null ? Padding( padding: const EdgeInsets.all(8.0), - child: Text(L10n.of(context).downloadComplete), + child: Text(_statusText!), ) : const SizedBox(), ), diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index d73d3bb21..94d992c07 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -120,6 +120,7 @@ abstract class ClientManager { PangeaEventTypes.capacity, EventTypes.RoomPowerLevels, PangeaEventTypes.userChosenEmoji, + EventTypes.RoomJoinRules, // Pangea# }, logLevel: kReleaseMode ? Level.warning : Level.verbose,