diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 98ee3a8d7..571178136 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4661,5 +4661,40 @@ "clickWordsInstructions": "Click on individual words for more activities.", "chooseBestDefinition": "Choose the best definition", "chooseBaseForm": "Choose the base form", - "notTheCodeError": "Sorry, that's not the code!" + "notTheCodeError": "Sorry, that's not the code!", + "totalXP": "Total XP", + "numLemmas": "Number of lemmas", + "listOfLemmas": "List of lemmas", + "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", + "numLemmasSmallXP": "Number of lemmas with 0 - 30 XP", + "numLemmasMediumXP": "Number of lemmas with 31 - 200 XP", + "numLemmasLargeXP": "Number of lemmas with > 200 XP", + "listLemmasSmallXP": "List of lemmas with 0 - 30 XP", + "listLemmasMediumXP": "List of lemmas with 31 - 200 XP", + "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", + "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", + "listGrammarConceptsLargeXP": "List of grammar concepts with 201 - 500 XP", + "listGrammarConceptsHugeXP": "List of grammar concepts with > 500 XP", + "numMessagesSent": "Number of messages sent", + "numWordsTyped": "Number of words typed in original messages", + "numCorrectChoices": "Number of correct words chosen from system-generated suggestions", + "numIncorrectChoices": "Number of incorrect words chosen from system-generated suggestions", + "downloadSpaceAnalytics": "Download space analytics", + "commaSeparatedFile": "CSV", + "excelFile": "Excel", + "fileType": "File type", + "download": "Download", + "analyticsNotAvailable": "User analytics not available", + "downloading": "Downloading...", + "failedFetchUserAnalytics": "Failed to download user analytics", + "downloadComplete": "Download complete!" } diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index b6b1d8509..da0bf26aa 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -23,6 +23,7 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; class GetAnalyticsController { late PangeaController _pangeaController; late MessageAnalyticsController perMessage; + final List _cache = []; StreamSubscription? _analyticsUpdateSubscription; StreamController analyticsStream = diff --git a/lib/pangea/controllers/put_analytics_controller.dart b/lib/pangea/controllers/put_analytics_controller.dart index dc72a0ef6..17ff50d8c 100644 --- a/lib/pangea/controllers/put_analytics_controller.dart +++ b/lib/pangea/controllers/put_analytics_controller.dart @@ -247,6 +247,17 @@ class PutAnalyticsController extends BaseController { try { final currentCache = _pangeaController.getAnalytics.messagesSinceUpdate; constructs.addAll(currentCache[cacheKey] ?? []); + + // if this is not a draft message, add the eventId to the metadata + // if it's missing (it will be missing for draft constructs) + if (!cacheKey.startsWith('draft')) { + constructs = constructs.map((construct) { + if (construct.metadata.eventId != null) return construct; + construct.metadata.eventId = cacheKey; + return construct; + }).toList(); + } + currentCache[cacheKey] = constructs; await _setMessagesSinceUpdate(currentCache); diff --git a/lib/pangea/enum/analytics/analytics_summary_enum.dart b/lib/pangea/enum/analytics/analytics_summary_enum.dart new file mode 100644 index 000000000..ce7d915ec --- /dev/null +++ b/lib/pangea/enum/analytics/analytics_summary_enum.dart @@ -0,0 +1,108 @@ +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +enum AnalyticsSummaryEnum { + username, + level, + totalXP, + numLemmas, + numLemmasUsedCorrectly, + numLemmasUsedIncorrectly, + // listLemmas, + // listLemmasUsedCorrectly, + // listLemmasUsedIncorrectly, + + /// 0 - 30 XP + numLemmasSmallXP, + // listLemmasSmallXP, + + /// 31 - 200 XP + numLemmasMediumXP, + // listLemmasMediumXP, + + /// > 200 XP + numLemmasLargeXP, + // listLemmasLargeXP, + + numMorphConstructs, + listMorphConstructs, + listMorphConstructsUsedCorrectly, + listMorphConstructsUsedIncorrectly, + + // list morph 0 - 30 XP + listMorphSmallXP, + + // list morph 31 - 200 XP + listMorphMediumXP, + + // list morph 200 - 500 XP + listMorphLargeXP, + + // list morph > 500 XP + listMorphHugeXP, + + numMessagesSent, + numWordsTyped, + numChoicesCorrect, + numChoicesIncorrect, +} + +extension AnalyticsSummaryEnumExtension on AnalyticsSummaryEnum { + String header(L10n l10n) { + switch (this) { + case AnalyticsSummaryEnum.username: + return l10n.username; + case AnalyticsSummaryEnum.level: + return l10n.level; + case AnalyticsSummaryEnum.totalXP: + return l10n.totalXP; + case AnalyticsSummaryEnum.numLemmas: + return l10n.numLemmas; + case AnalyticsSummaryEnum.numLemmasUsedCorrectly: + return l10n.numLemmasUsedCorrectly; + case AnalyticsSummaryEnum.numLemmasUsedIncorrectly: + return l10n.numLemmasUsedIncorrectly; + // case AnalyticsSummaryEnum.listLemmas: + // return l10n.listOfLemmas; + // case AnalyticsSummaryEnum.listLemmasUsedCorrectly: + // return l10n.listLemmasUsedCorrectly; + // case AnalyticsSummaryEnum.listLemmasUsedIncorrectly: + // return l10n.listLemmasUsedIncorrectly; + case AnalyticsSummaryEnum.numLemmasSmallXP: + return l10n.numLemmasSmallXP; + case AnalyticsSummaryEnum.numLemmasMediumXP: + return l10n.numLemmasMediumXP; + case AnalyticsSummaryEnum.numLemmasLargeXP: + return l10n.numLemmasLargeXP; + // case AnalyticsSummaryEnum.listLemmasSmallXP: + // return l10n.listLemmasSmallXP; + // case AnalyticsSummaryEnum.listLemmasMediumXP: + // return l10n.listLemmasMediumXP; + // case AnalyticsSummaryEnum.listLemmasLargeXP: + // return l10n.listLemmasLargeXP; + case AnalyticsSummaryEnum.numMorphConstructs: + return l10n.numGrammarConcepts; + case AnalyticsSummaryEnum.listMorphConstructs: + return l10n.listGrammarConcepts; + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectly: + return l10n.listGrammarConceptsUsedCorrectly; + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectly: + return l10n.listGrammarConceptsUsedIncorrectly; + case AnalyticsSummaryEnum.listMorphSmallXP: + return l10n.listGrammarConceptsSmallXP; + case AnalyticsSummaryEnum.listMorphMediumXP: + return l10n.listGrammarConceptsMediumXP; + case AnalyticsSummaryEnum.listMorphLargeXP: + return l10n.listGrammarConceptsLargeXP; + case AnalyticsSummaryEnum.listMorphHugeXP: + return l10n.listGrammarConceptsHugeXP; + case AnalyticsSummaryEnum.numMessagesSent: + return l10n.numMessagesSent; + case AnalyticsSummaryEnum.numWordsTyped: + return l10n.numWordsTyped; + case AnalyticsSummaryEnum.numChoicesCorrect: + return l10n.numCorrectChoices; + case AnalyticsSummaryEnum.numChoicesIncorrect: + return l10n.numIncorrectChoices; + } + } +} diff --git a/lib/pangea/enum/construct_use_type_enum.dart b/lib/pangea/enum/construct_use_type_enum.dart index 6fc19c601..d97d8fb46 100644 --- a/lib/pangea/enum/construct_use_type_enum.dart +++ b/lib/pangea/enum/construct_use_type_enum.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/enum/analytics/analytics_summary_enum.dart'; enum ConstructUseTypeEnum { /// produced in chat by user, igc was run, and we've judged it to be a correct use @@ -215,6 +216,78 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { return -3; } } + + bool get sentByUser { + switch (this) { + case ConstructUseTypeEnum.wa: + case ConstructUseTypeEnum.ga: + case ConstructUseTypeEnum.unk: + case ConstructUseTypeEnum.corIt: + case ConstructUseTypeEnum.ignIt: + case ConstructUseTypeEnum.incIt: + case ConstructUseTypeEnum.corIGC: + case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.ignIGC: + return true; + + case ConstructUseTypeEnum.corPA: + case ConstructUseTypeEnum.ignPA: + case ConstructUseTypeEnum.incPA: + case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.corHWL: + case ConstructUseTypeEnum.incHWL: + case ConstructUseTypeEnum.ignHWL: + case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.ignL: + case ConstructUseTypeEnum.corM: + case ConstructUseTypeEnum.incM: + case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.em: + case ConstructUseTypeEnum.nan: + return false; + } + } + + AnalyticsSummaryEnum? get summaryEnumType { + switch (this) { + case ConstructUseTypeEnum.wa: + case ConstructUseTypeEnum.ga: + case ConstructUseTypeEnum.unk: + return AnalyticsSummaryEnum.numWordsTyped; + + case ConstructUseTypeEnum.corIt: + case ConstructUseTypeEnum.corPA: + case ConstructUseTypeEnum.corIGC: + case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.corHWL: + case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.corM: + case ConstructUseTypeEnum.em: + return AnalyticsSummaryEnum.numChoicesCorrect; + + case ConstructUseTypeEnum.incIt: + case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.incPA: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.incHWL: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.incM: + return AnalyticsSummaryEnum.numChoicesIncorrect; + + case ConstructUseTypeEnum.ignIt: + case ConstructUseTypeEnum.ignPA: + case ConstructUseTypeEnum.ignIGC: + case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.ignHWL: + case ConstructUseTypeEnum.ignL: + case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.nan: + return null; + } + } } class ConstructUseTypeUtil { diff --git a/lib/pangea/extensions/client_extension/client_analytics_extension.dart b/lib/pangea/extensions/client_extension/client_analytics_extension.dart index 34e86bbd7..7226e439d 100644 --- a/lib/pangea/extensions/client_extension/client_analytics_extension.dart +++ b/lib/pangea/extensions/client_extension/client_analytics_extension.dart @@ -85,16 +85,25 @@ extension AnalyticsClientExtension on Client { // so they will appear in the space hierarchy Future _updateAnalyticsRoomVisibility() async { if (userID == null || userID == BotName.byEnvironment) return; + + final visibilityFutures = allMyAnalyticsRooms.map((room) async { + final visability = await getRoomVisibilityOnDirectory(room.id); + if (visability != Visibility.private) { + await setRoomVisibilityOnDirectory( + room.id, + visibility: Visibility.private, + ); + } + }).toList(); + + final joinRulesFutures = allMyAnalyticsRooms.map((room) async { + if (room.joinRules != JoinRules.public) { + await room.setJoinRules(JoinRules.public); + } + }).toList(); + await Future.wait( - allMyAnalyticsRooms.map((room) async { - final visability = await getRoomVisibilityOnDirectory(room.id); - if (visability != Visibility.public) { - await setRoomVisibilityOnDirectory( - room.id, - visibility: Visibility.public, - ); - } - }), + visibilityFutures + joinRulesFutures, ); } 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 b43afda74..fd0c6dd06 100644 --- a/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart +++ b/lib/pangea/extensions/pangea_room_extension/room_analytics_extension.dart @@ -152,7 +152,8 @@ extension AnalyticsRoomExtension on Room { } Future _analyticsLastUpdated(String userId) async { - final List events = await getRoomAnalyticsEvents(count: 1); + final List events = + await getRoomAnalyticsEvents(count: 1, userID: userId); if (events.isEmpty) return null; return events.first.originServerTs; } @@ -161,7 +162,7 @@ extension AnalyticsRoomExtension on Room { required String userId, DateTime? since, }) async { - final events = await getRoomAnalyticsEvents(); + final events = await getRoomAnalyticsEvents(userID: userId); final List analyticsEvents = []; for (final Event event in events) { analyticsEvents.add(ConstructAnalyticsEvent(event: event)); diff --git a/lib/pangea/models/analytics/analytics_summary_model.dart b/lib/pangea/models/analytics/analytics_summary_model.dart new file mode 100644 index 000000000..cf3874f27 --- /dev/null +++ b/lib/pangea/models/analytics/analytics_summary_model.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.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'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; + +class AnalyticsSummaryModel { + String username; + int level; + int totalXP; + + int numLemmas; + int numLemmasUsedCorrectly; + int numLemmasUsedIncorrectly; + // List listLemmas; + // List listLemmasUsedCorrectly; + // List listLemmasUsedIncorrectly; + + /// 0 - 30 XP + int numLemmasSmallXP; + // List listLemmasSmallXP; + + /// 31 - 200 XP + int numLemmasMediumXP; + // List listLemmasMediumXP; + + /// > 200 XP + int numLemmasLargeXP; + // List listLemmasLargeXP; + + int numMorphConstructs; + List listMorphConstructs; + List listMorphConstructsUsedCorrectly; + List listMorphConstructsUsedIncorrectly; + + // list morph 0 - 30 XP + List listMorphSmallXP; + + // list morph 31 - 200 XP + List listMorphMediumXP; + + // list morph 200 - 500 XP + List listMorphLargeXP; + + // list morph > 500 XP + List listMorphHugeXP; + + 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.listLemmas, + // required this.listLemmasUsedCorrectly, + // required this.listLemmasUsedIncorrectly, + required this.numLemmasSmallXP, + required this.numLemmasMediumXP, + required this.numLemmasLargeXP, + // required this.listLemmasSmallXP, + // required this.listLemmasMediumXP, + // required this.listLemmasLargeXP, + 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, + }); + + static AnalyticsSummaryModel fromConstructListModel( + ConstructListModel model, + String userID, + String Function(ConstructUses) getCopy, + BuildContext context, + ) { + final vocabLemmas = LemmasToUsesWrapper( + model.lemmasToUses(type: ConstructTypeEnum.vocab), + ); + final morphLemmas = LemmasToUsesWrapper( + model.lemmasToUses(type: ConstructTypeEnum.morph), + ); + + final morphLemmasPercentCorrect = morphLemmas.lemmasByPercent( + percent: 0.8, + getCopy: getCopy, + ); + + 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++; + } + } + + final numMessageSent = model.uses + .where((use) => use.useType.sentByUser) + .map((use) => use.metadata.eventId) + .toSet() + .length; + + 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, + numLemmasMediumXP: + 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( + start: 0, + end: 30, + getCopy: getCopy, + ), + listMorphMediumXP: morphLemmas.thresholdedLemmas( + start: 31, + end: 200, + getCopy: getCopy, + ), + listMorphLargeXP: morphLemmas.thresholdedLemmas( + start: 201, + end: 500, + getCopy: getCopy, + ), + listMorphHugeXP: morphLemmas.thresholdedLemmas( + start: 501, + getCopy: getCopy, + ), + numMessagesSent: numMessageSent, + numWordsTyped: numWordsTyped, + numChoicesCorrect: numChoicesCorrect, + numChoicesIncorrect: numChoicesIncorrect, + ); + } + + dynamic getValue(AnalyticsSummaryEnum key) { + switch (key) { + case AnalyticsSummaryEnum.username: + return username; + case AnalyticsSummaryEnum.level: + return level; + case AnalyticsSummaryEnum.totalXP: + return totalXP; + case AnalyticsSummaryEnum.numLemmas: + return numLemmas; + case AnalyticsSummaryEnum.numLemmasUsedCorrectly: + return numLemmasUsedCorrectly; + case AnalyticsSummaryEnum.numLemmasUsedIncorrectly: + return numLemmasUsedIncorrectly; + // case AnalyticsSummaryEnum.listLemmas: + // return listLemmas; + // case AnalyticsSummaryEnum.listLemmasUsedCorrectly: + // return listLemmasUsedCorrectly; + // case AnalyticsSummaryEnum.listLemmasUsedIncorrectly: + // return listLemmasUsedIncorrectly; + case AnalyticsSummaryEnum.numLemmasSmallXP: + return numLemmasSmallXP; + case AnalyticsSummaryEnum.numLemmasMediumXP: + return numLemmasMediumXP; + case AnalyticsSummaryEnum.numLemmasLargeXP: + return numLemmasLargeXP; + // case AnalyticsSummaryEnum.listLemmasSmallXP: + // return listLemmasSmallXP; + // case AnalyticsSummaryEnum.listLemmasMediumXP: + // return listLemmasMediumXP; + // case AnalyticsSummaryEnum.listLemmasLargeXP: + // return listLemmasLargeXP; + case AnalyticsSummaryEnum.numMorphConstructs: + return numMorphConstructs; + case AnalyticsSummaryEnum.listMorphConstructs: + return listMorphConstructs; + case AnalyticsSummaryEnum.listMorphConstructsUsedCorrectly: + return listMorphConstructsUsedCorrectly; + case AnalyticsSummaryEnum.listMorphConstructsUsedIncorrectly: + return listMorphConstructsUsedIncorrectly; + case AnalyticsSummaryEnum.listMorphSmallXP: + return listMorphSmallXP; + case AnalyticsSummaryEnum.listMorphMediumXP: + return listMorphMediumXP; + case AnalyticsSummaryEnum.listMorphLargeXP: + return listMorphLargeXP; + case AnalyticsSummaryEnum.listMorphHugeXP: + return listMorphHugeXP; + case AnalyticsSummaryEnum.numMessagesSent: + return numMessagesSent; + case AnalyticsSummaryEnum.numWordsTyped: + return numWordsTyped; + case AnalyticsSummaryEnum.numChoicesCorrect: + return numChoicesCorrect; + case AnalyticsSummaryEnum.numChoicesIncorrect: + return numChoicesIncorrect; + } + } + + Map toJson() { + return { + 'username': username, + 'level': level, + 'totalXP': totalXP, + 'numLemmas': numLemmas, + 'numLemmasUsedCorrectly': numLemmasUsedCorrectly, + 'numLemmasUsedIncorrectly': numLemmasUsedIncorrectly, + // 'listLemmas': listLemmas, + // 'listLemmasUsedCorrectly': listLemmasUsedCorrectly, + // 'listLemmasUsedIncorrectly': listLemmasUsedIncorrectly, + 'numLemmasSmallXP': numLemmasSmallXP, + 'numLemmasMediumXP': numLemmasMediumXP, + 'numLemmasLargeXP': numLemmasLargeXP, + // 'listLemmasSmallXP': listLemmasSmallXP, + // 'listLemmasMediumXP': listLemmasMediumXP, + // 'listLemmasLargeXP': listLemmasLargeXP, + 'numMorphConstructs': numMorphConstructs, + 'listMorphConstructs': listMorphConstructs, + 'listMorphConstructsUsedCorrectly': listMorphConstructsUsedCorrectly, + 'listMorphConstructsUsedIncorrectly': listMorphConstructsUsedIncorrectly, + 'listMorphSmallXP': listMorphSmallXP, + 'listMorphMediumXP': listMorphMediumXP, + 'listMorphLargeXP': listMorphLargeXP, + 'listMorphHugeXP': listMorphHugeXP, + 'numMessagesSent': numMessagesSent, + 'numWordsWithoutAssistance': numWordsTyped, + 'numChoicesCorrect': numChoicesCorrect, + 'numChoicesIncorrect': numChoicesIncorrect, + }; + } +} diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index f8f049186..8bde61481 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -1,13 +1,10 @@ import 'dart:math'; -import 'package:flutter/foundation.dart'; - import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -20,13 +17,12 @@ class ConstructListModel { prevXP = 0; totalXP = 0; level = 0; - vocabLemmas = 0; - grammarLemmas = 0; _uses.clear(); } - List _uses = []; + final List _uses = []; List get uses => _uses; + List get truncatedUses => _uses.take(100).toList(); /// A map of lemmas to ConstructUses, each of which contains a lemma /// key = lemmma + constructType.string, value = ConstructUses @@ -39,12 +35,16 @@ class ConstructListModel { /// A map of categories to lists of ConstructUses Map> _categoriesToUses = {}; + /// A list of unique vocab lemmas + List vocabLemmasList = []; + + /// A list of unique grammar lemmas + List grammarLemmasList = []; + /// Analytics data consumed by widgets. Updated each time new analytics come in. int prevXP = 0; int totalXP = 0; int level = 0; - int vocabLemmas = 0; - int grammarLemmas = 0; ConstructListModel({ required List uses, @@ -52,6 +52,11 @@ class ConstructListModel { updateConstructs(uses); } + int get totalLemmas => vocabLemmasList.length + grammarLemmasList.length; + int get vocabLemmas => vocabLemmasList.length; + int get grammarLemmas => grammarLemmasList.length; + List get lemmasList => vocabLemmasList + grammarLemmasList; + /// Given a list of new construct uses, update the map of construct /// IDs to ConstructUses and re-sort the list of ConstructUses void updateConstructs(List newUses) { @@ -81,7 +86,6 @@ class ConstructListModel { void _updateUsesList(List newUses) { newUses.sort((a, b) => b.timeStamp.compareTo(a.timeStamp)); _uses.insertAll(0, newUses); - _uses = _uses.take(100).toList(); } /// A map of lemmas to ConstructUses, each of which contains a lemma @@ -141,15 +145,15 @@ class ConstructListModel { } void _updateMetrics() { - vocabLemmas = constructList(type: ConstructTypeEnum.vocab) + vocabLemmasList = constructList(type: ConstructTypeEnum.vocab) .map((e) => e.lemma) .toSet() - .length; + .toList(); - grammarLemmas = constructList(type: ConstructTypeEnum.morph) + grammarLemmasList = constructList(type: ConstructTypeEnum.morph) .map((e) => e.lemma) .toSet() - .length; + .toList(); prevXP = totalXP; totalXP = _constructList.fold( @@ -205,9 +209,7 @@ class ConstructListModel { List constructList({ConstructTypeEnum? type}) => _constructList .where( - (constructUse) => - constructUse.points > 0 && - (type == null || constructUse.constructType == type), + (constructUse) => type == null || constructUse.constructType == type, ) .toList(); @@ -224,91 +226,111 @@ class ConstructListModel { ); } - List morphActivityDistractors( - String morphFeature, - String morphTag, - ) { - final List morphConstructs = constructList( - type: ConstructTypeEnum.morph, - ); - final List possibleDistractors = morphConstructs - .where( - (c) => - c.category == morphFeature.toLowerCase() && - c.lemma.toLowerCase() != morphTag.toLowerCase() && - c.lemma.isNotEmpty && - c.lemma != "X", - ) - .map((c) => c.lemma) - .toList(); - - possibleDistractors.shuffle(); - return possibleDistractors.take(3).toList(); - } - - Future> lemmaActivityDistractors(PangeaToken token) async { - final List lemmas = constructList(type: ConstructTypeEnum.vocab) - .map((c) => c.lemma) - .toSet() - .toList(); - - // Offload computation to an isolate - final Map distances = - await compute(_computeDistancesInIsolate, { - 'lemmas': lemmas, - 'target': token.lemma.text, - }); - - // Sort lemmas by distance - final sortedLemmas = distances.keys.toList() - ..sort((a, b) => distances[a]!.compareTo(distances[b]!)); - - // Take the shortest 4 - final choices = sortedLemmas.take(4).toList(); - if (!choices.contains(token.lemma.text)) { - final random = Random(); - choices[random.nextInt(4)] = token.lemma.text; + // Not storing this for now to reduce memory load + // It's only used by downloads, so doesn't need to be accessible on the fly + Map> lemmasToUses({ + ConstructTypeEnum? type, + }) { + final Map> lemmasToUses = {}; + final constructs = constructList(type: type); + for (final ConstructUses use in constructs) { + final lemma = use.lemma; + lemmasToUses.putIfAbsent(lemma, () => []); + lemmasToUses[lemma]!.add(use); } - return choices; - } - - // isolate helper function - Map _computeDistancesInIsolate(Map params) { - final List lemmas = params['lemmas']; - final String target = params['target']; - - // Calculate Levenshtein distances - final Map distances = {}; - for (final lemma in lemmas) { - distances[lemma] = levenshteinDistanceSync(target, lemma); - } - return distances; - } - - int levenshteinDistanceSync(String s, String t) { - final int m = s.length; - final int n = t.length; - final List> dp = List.generate( - m + 1, - (_) => List.generate(n + 1, (_) => 0), - ); - - for (int i = 0; i <= m; i++) { - for (int j = 0; j <= n; j++) { - if (i == 0) { - dp[i][j] = j; - } else if (j == 0) { - dp[i][j] = i; - } else if (s[i - 1] == t[j - 1]) { - dp[i][j] = dp[i - 1][j - 1]; - } else { - dp[i][j] = 1 + - [dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]] - .reduce((a, b) => a < b ? a : b); - } - } - } - - return dp[m][n]; + return lemmasToUses; } } + +class LemmasToUsesWrapper { + final Map> lemmasToUses; + + LemmasToUsesWrapper(this.lemmasToUses); + + /// Return an object containing two lists, one of lemmas with + /// any correct uses and one of lemmas with any incorrect uses + LemmasOverUnderList lemmasByCorrectUse({ + String Function(ConstructUses)? getCopy, + }) { + final List correctLemmas = []; + final List incorrectLemmas = []; + for (final entry in lemmasToUses.entries) { + final lemma = entry.key; + final constructUses = entry.value; + final copy = getCopy?.call(constructUses.first) ?? lemma; + if (constructUses.any((use) => use.hasCorrectUse)) { + correctLemmas.add(copy); + } + if (constructUses.any((use) => use.hasIncorrectUse)) { + 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; + if (uses.length == 1) return uses.first.points; + return lemmasToUses[lemma]!.fold( + 0, + (total, use) => total + use.points, + ); + } + + List thresholdedLemmas({ + required int start, + int? end, + String Function(ConstructUses)? getCopy, + }) { + final filteredList = lemmasToUses.entries.where((entry) { + final xp = totalXP(entry.key); + return xp >= start && (end == null || xp <= end); + }); + return filteredList + .map((entry) => getCopy?.call(entry.value.first) ?? entry.key) + .toList(); + } +} + +class LemmasOverUnderList { + final List over; + final List under; + + LemmasOverUnderList({ + required this.over, + required this.under, + }); +} diff --git a/lib/pangea/models/analytics/construct_use_model.dart b/lib/pangea/models/analytics/construct_use_model.dart index 8a796c457..46319f85d 100644 --- a/lib/pangea/models/analytics/construct_use_model.dart +++ b/lib/pangea/models/analytics/construct_use_model.dart @@ -44,6 +44,9 @@ class ConstructUses { return _category!.toLowerCase(); } + bool get hasCorrectUse => uses.any((use) => use.pointValue > 0); + bool get hasIncorrectUse => uses.any((use) => use.pointValue < 0); + ConstructIdentifier get id => ConstructIdentifier( lemma: lemma, type: constructType, diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index 8d0920e55..0568816c0 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -359,8 +360,6 @@ class PangeaToken { String? morphFeature, String? morphTag, }) { - final constructListModel = - MatrixState.pangeaController.getAnalytics.constructListModel; switch (type) { case ActivityTypeEnum.lemmaId: // the function to determine this for lemmas is async @@ -368,7 +367,7 @@ class PangeaToken { debugger(when: kDebugMode); return false; case ActivityTypeEnum.morphId: - final distractors = constructListModel.morphActivityDistractors( + final distractors = morphActivityDistractors( morphFeature!, morphTag!, ); @@ -386,9 +385,7 @@ class PangeaToken { } Future canGenerateLemmaDistractors() async { - final constructListModel = - MatrixState.pangeaController.getAnalytics.constructListModel; - final distractors = await constructListModel.lemmaActivityDistractors(this); + final distractors = await lemmaActivityDistractors(this); return distractors.isNotEmpty; } @@ -611,4 +608,94 @@ class PangeaToken { return "🌺"; } } + + List morphActivityDistractors( + String morphFeature, + String morphTag, + ) { + final List morphConstructs = MatrixState + .pangeaController.getAnalytics.constructListModel + .constructList(type: ConstructTypeEnum.morph); + final List possibleDistractors = morphConstructs + .where( + (c) => + c.category == morphFeature.toLowerCase() && + c.lemma.toLowerCase() != morphTag.toLowerCase() && + c.lemma.isNotEmpty && + c.lemma != "X", + ) + .map((c) => c.lemma) + .toList(); + + possibleDistractors.shuffle(); + return possibleDistractors.take(3).toList(); + } + + Future> lemmaActivityDistractors(PangeaToken token) async { + final List lemmas = MatrixState + .pangeaController.getAnalytics.constructListModel + .constructList(type: ConstructTypeEnum.vocab) + .map((c) => c.lemma) + .toSet() + .toList(); + + // Offload computation to an isolate + final Map distances = + await compute(_computeDistancesInIsolate, { + 'lemmas': lemmas, + 'target': token.lemma.text, + }); + + // Sort lemmas by distance + final sortedLemmas = distances.keys.toList() + ..sort((a, b) => distances[a]!.compareTo(distances[b]!)); + + // Take the shortest 4 + final choices = sortedLemmas.take(4).toList(); + if (!choices.contains(token.lemma.text)) { + final random = Random(); + choices[random.nextInt(4)] = token.lemma.text; + } + return choices; + } + + // isolate helper function + Map _computeDistancesInIsolate(Map params) { + final List lemmas = params['lemmas']; + final String target = params['target']; + + // Calculate Levenshtein distances + final Map distances = {}; + for (final lemma in lemmas) { + distances[lemma] = levenshteinDistanceSync(target, lemma); + } + return distances; + } + + int levenshteinDistanceSync(String s, String t) { + final int m = s.length; + final int n = t.length; + final List> dp = List.generate( + m + 1, + (_) => List.generate(n + 1, (_) => 0), + ); + + for (int i = 0; i <= m; i++) { + for (int j = 0; j <= n; j++) { + if (i == 0) { + dp[i][j] = j; + } else if (j == 0) { + dp[i][j] = i; + } else if (s[i - 1] == t[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = 1 + + [dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]] + .reduce((a, b) => a < b ? a : b); + } + } + } + + return dp[m][n]; + } } diff --git a/lib/pangea/pages/chat_details/pangea_chat_details.dart b/lib/pangea/pages/chat_details/pangea_chat_details.dart index 5e032024a..f6ccfb3ff 100644 --- a/lib/pangea/pages/chat_details/pangea_chat_details.dart +++ b/lib/pangea/pages/chat_details/pangea_chat_details.dart @@ -11,8 +11,10 @@ import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; import 'package:fluffychat/pangea/pages/class_settings/class_name_header.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/class_details_toggle_add_students_tile.dart'; +import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/download_analytics_button.dart'; import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_capacity_button.dart'; import 'package:fluffychat/pangea/utils/download_chat.dart'; +import 'package:fluffychat/pangea/utils/download_file.dart'; import 'package:fluffychat/pangea/widgets/chat/visibility_toggle.dart'; import 'package:fluffychat/pangea/widgets/conversation_bot/conversation_bot_settings.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; @@ -335,6 +337,8 @@ class PangeaChatDetailsView extends StatelessWidget { room: room, controller: controller, ), + if (room.isSpace && room.isRoomAdmin) + DownloadAnalyticsButton(space: room), Divider(color: theme.dividerColor, height: 1), if (isGroupChat) ListTile( diff --git a/lib/pangea/pages/class_settings/p_class_widgets/download_analytics_button.dart b/lib/pangea/pages/class_settings/p_class_widgets/download_analytics_button.dart new file mode 100644 index 000000000..200a5249d --- /dev/null +++ b/lib/pangea/pages/class_settings/p_class_widgets/download_analytics_button.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/widgets/download_analytics_dialog.dart'; + +class DownloadAnalyticsButton extends StatelessWidget { + final Room space; + + const DownloadAnalyticsButton({ + super.key, + required this.space, + }); + + @override + Widget build(BuildContext context) { + final iconColor = Theme.of(context).textTheme.bodyLarge!.color; + return Column( + children: [ + ListTile( + onTap: () { + showDialog( + context: context, + builder: (context) => DownloadAnalyticsDialog(space: space), + ); + }, + leading: CircleAvatar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + foregroundColor: iconColor, + child: const Icon(Icons.download_outlined), + ), + title: Text( + L10n.of(context).downloadSpaceAnalytics, + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pangea/repo/practice/lemma_activity_generator.dart b/lib/pangea/repo/practice/lemma_activity_generator.dart index c2f64f183..9688bffe5 100644 --- a/lib/pangea/repo/practice/lemma_activity_generator.dart +++ b/lib/pangea/repo/practice/lemma_activity_generator.dart @@ -9,7 +9,6 @@ import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; class LemmaActivityGenerator { Future get( @@ -19,9 +18,7 @@ class LemmaActivityGenerator { debugger(when: kDebugMode && req.targetTokens.length != 1); final token = req.targetTokens.first; - final List choices = await MatrixState - .pangeaController.getAnalytics.constructListModel - .lemmaActivityDistractors(token); + final List choices = await token.lemmaActivityDistractors(token); // TODO - modify MultipleChoiceActivity flow to allow no correct answer return MessageActivityResponse( diff --git a/lib/pangea/repo/practice/morph_activity_generator.dart b/lib/pangea/repo/practice/morph_activity_generator.dart index 98ff9eb3c..7b0563fed 100644 --- a/lib/pangea/repo/practice/morph_activity_generator.dart +++ b/lib/pangea/repo/practice/morph_activity_generator.dart @@ -75,9 +75,8 @@ class MorphActivityGenerator { throw "No morph tag found for morph feature"; } - final List distractors = MatrixState - .pangeaController.getAnalytics.constructListModel - .morphActivityDistractors(morphFeature, morphTag); + final List distractors = + token.morphActivityDistractors(morphFeature, morphTag); return MessageActivityResponse( activity: PracticeActivityModel( diff --git a/lib/pangea/utils/download_chat.dart b/lib/pangea/utils/download_chat.dart index 68429bc75..a867700d1 100644 --- a/lib/pangea/utils/download_chat.dart +++ b/lib/pangea/utils/download_chat.dart @@ -1,9 +1,7 @@ // ignore_for_file: implementation_imports import 'dart:async'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:csv/csv.dart'; @@ -12,17 +10,12 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:intl/intl.dart'; import 'package:matrix/matrix.dart'; import 'package:matrix/src/models/timeline_chunk.dart'; -import 'package:open_file/open_file.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:universal_html/html.dart' as webfile; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/utils/download_file.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import '../models/choreo_record.dart'; -enum DownloadType { txt, csv, xlsx } - Future downloadChat( Room room, DownloadType type, @@ -192,52 +185,6 @@ String mimetype(DownloadType fileType) { } } -Future downloadFile( - contents, - String filename, - DownloadType fileType, -) async { - if (kIsWeb) { - final blob = webfile.Blob([contents], mimetype(fileType), 'native'); - webfile.AnchorElement( - href: webfile.Url.createObjectUrlFromBlob(blob).toString(), - ) - ..setAttribute("download", filename) - ..click(); - return; - } - if (await Permission.storage.request().isGranted) { - Directory? directory; - try { - if (Platform.isIOS) { - directory = await getApplicationDocumentsDirectory(); - } else { - directory = Directory('/storage/emulated/0/Download'); - if (!await directory.exists()) { - directory = await getExternalStorageDirectory(); - } - } - } catch (err, s) { - debugPrint("Failed to get download folder path"); - ErrorHandler.logError( - e: err, - s: s, - data: {}, - ); - } - if (directory != null) { - final File f = File("${directory.path}/$filename"); - File resp; - if (fileType == DownloadType.txt || fileType == DownloadType.csv) { - resp = await f.writeAsString(contents); - } else { - resp = await f.writeAsBytes(contents); - } - OpenFile.open(resp.path); - } - } -} - String getTxtContent( List messages, BuildContext context, diff --git a/lib/pangea/utils/download_file.dart b/lib/pangea/utils/download_file.dart new file mode 100644 index 000000000..7431be6e0 --- /dev/null +++ b/lib/pangea/utils/download_file.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import 'package:open_file/open_file.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:universal_html/html.dart' as webfile; + +import 'package:fluffychat/pangea/utils/download_chat.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; + +enum DownloadType { txt, csv, xlsx } + +Future downloadFile( + dynamic contents, + String filename, + DownloadType fileType, +) async { + if (kIsWeb) { + final blob = webfile.Blob([contents], mimetype(fileType), 'native'); + webfile.AnchorElement( + href: webfile.Url.createObjectUrlFromBlob(blob).toString(), + ) + ..setAttribute("download", filename) + ..click(); + return; + } + if (await Permission.storage.request().isGranted) { + Directory? directory; + try { + if (Platform.isIOS) { + directory = await getApplicationDocumentsDirectory(); + } else { + directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + directory = await getExternalStorageDirectory(); + } + } + } catch (err, s) { + debugPrint("Failed to get download folder path"); + ErrorHandler.logError( + e: err, + s: s, + data: {}, + ); + } + if (directory != null) { + final File f = File("${directory.path}/$filename"); + File resp; + if (fileType == DownloadType.txt || fileType == DownloadType.csv) { + resp = await f.writeAsString(contents); + } else { + resp = await f.writeAsBytes(contents); + } + OpenFile.open(resp.path); + } + } +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart index ebd5dabfd..141dbe9ed 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart @@ -86,7 +86,9 @@ class AnalyticsPopupState extends State { ), Expanded( child: ConstructsTileList( - _categoriesToUses[selectedCategory]!, + _categoriesToUses[selectedCategory]! + .where((use) => use.points > 0) + .toList(), ), ), ], @@ -95,7 +97,10 @@ class AnalyticsPopupState extends State { dialogContent = Center(child: Text(L10n.of(context).noDataFound)); } else if (hasNoCategories || !widget.showGroups) { dialogContent = ConstructsTileList( - _constructsModel.constructList(type: widget.type), + _constructsModel + .constructList(type: widget.type) + .where((uses) => uses.points > 0) + .toList(), ); } else { dialogContent = ListView.builder( diff --git a/lib/pangea/widgets/chat_list/analytics_summary/level_bar_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/level_bar_popup.dart index bf35b215e..876d75531 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/level_bar_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/level_bar_popup.dart @@ -22,7 +22,7 @@ class LevelBarPopup extends StatelessWidget { int get totalXP => getAnalyticsController.constructListModel.totalXP; int get maxLevelXP => getAnalyticsController.minXPForNextLevel; List get uses => - getAnalyticsController.constructListModel.uses; + getAnalyticsController.constructListModel.truncatedUses; @override Widget build(BuildContext context) { diff --git a/lib/pangea/widgets/download_analytics_dialog.dart b/lib/pangea/widgets/download_analytics_dialog.dart new file mode 100644 index 000000000..459379df0 --- /dev/null +++ b/lib/pangea/widgets/download_analytics_dialog.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:csv/csv.dart'; +import 'package:excel/excel.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/constants/class_default_values.dart'; +import 'package:fluffychat/pangea/enum/analytics/analytics_summary_enum.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/models/analytics/analytics_summary_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/utils/bot_name.dart'; +import 'package:fluffychat/pangea/utils/download_file.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class DownloadAnalyticsDialog extends StatefulWidget { + final Room space; + const DownloadAnalyticsDialog({ + required this.space, + super.key, + }); + + @override + DownloadAnalyticsDialogState createState() => DownloadAnalyticsDialogState(); +} + +class DownloadAnalyticsDialogState extends State { + bool _initialized = false; + bool _loading = false; + bool _finishedDownload = false; + String? _error; + + Map _downloadStatues = {}; + + @override + void initState() { + super.initState(); + widget.space + .requestParticipants([Membership.join], false, true).whenComplete(() { + _resetDownloadStatuses(); + _initialized = true; + if (mounted) setState(() {}); + }).catchError((e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "spaceID": widget.space.id, + }, + ); + if (mounted) setState(() => _error = e.toString()); + return []; + }); + } + + List get _usersToDownload => widget.space + .getParticipants() + .where( + (member) => + widget.space.getPowerLevelByUserId(member.id) < + ClassDefaultValues.powerLevelOfAdmin && + member.id != BotName.byEnvironment, + ) + .toList(); + + Color _downloadStatusColor(String userID) { + final status = _downloadStatues[userID]; + if (status == 1) return Colors.yellow; + if (status == 2) return Colors.green; + if (status == -1) return Colors.red; + return Colors.grey; + } + + Room? _userAnalyticsRoom(String userID) { + final rooms = widget.space.client.rooms; + final l2 = MatrixState.pangeaController.languageController.userL2?.langCode; + if (l2 == null) return null; + return rooms.firstWhereOrNull((room) { + return room.isAnalyticsRoomOfUser(userID) && room.isMadeForLang(l2); + }); + } + + Future _runDownload() async { + try { + _loading = true; + _error = null; + _resetDownloadStatuses(); + if (mounted) setState(() {}); + await _downloadSpaceAnalytics(); + if (mounted) setState(() => _finishedDownload = true); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "spaceID": widget.space.id, + }, + ); + _resetDownloadStatuses(); + _error = e.toString(); + if (mounted) setState(() {}); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _downloadSpaceAnalytics() async { + final l2 = MatrixState.pangeaController.languageController.userL2?.langCode; + if (l2 == null) return; + + final List summaries = await Future.wait( + _usersToDownload.map((user) => _getUserAnalyticsModel(user.id)), + ); + + final allSummaries = summaries.whereType().toList(); + final content = _downloadType == DownloadType.xlsx + ? _getExcelFileContent(allSummaries) + : _getCSVFileContent(allSummaries); + + final fileName = + "analytics_${widget.space.name}_${DateTime.now().toIso8601String()}.${_downloadType == DownloadType.xlsx ? 'xlsx' : 'csv'}"; + + await downloadFile( + content, + fileName, + DownloadType.csv, + ); + } + + Future _getUserAnalyticsModel(String userID) async { + try { + setState(() => _downloadStatues[userID] = 1); + final userAnalyticsRoom = _userAnalyticsRoom(userID); + final constructEvents = await userAnalyticsRoom?.getAnalyticsEvents( + userId: userID, + ); + if (constructEvents == null) { + setState(() => _downloadStatues[userID] = 0); + return null; + } + + final List uses = []; + for (final event in constructEvents) { + uses.addAll(event.content.uses); + } + + final constructs = ConstructListModel(uses: uses); + final summary = AnalyticsSummaryModel.fromConstructListModel( + constructs, + userID, + getCopy, + context, + ); + setState(() => _downloadStatues[userID] = 2); + return summary; + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "spaceID": widget.space.id, + "userID": userID, + }, + ); + setState(() => _downloadStatues[userID] = -1); + } + return null; + } + + List _formatExcelRow( + AnalyticsSummaryModel summary, + ) { + final List row = []; + for (int i = 0; i < AnalyticsSummaryEnum.values.length; i++) { + final key = AnalyticsSummaryEnum.values[i]; + final value = summary.getValue(key); + if (value is int) { + row.add(IntCellValue(value)); + } else if (value is String) { + row.add(TextCellValue(value)); + } else if (value is List) { + row.add(TextCellValue(value.join(", "))); + } + } + return row; + } + + List _getExcelFileContent( + List summaries, + ) { + final excel = Excel.createExcel(); + final sheet = excel['Sheet1']; + + for (final key in AnalyticsSummaryEnum.values) { + sheet + .cell( + CellIndex.indexByColumnRow( + rowIndex: 0, + columnIndex: key.index, + ), + ) + .value = TextCellValue(key.header(L10n.of(context))); + } + + final rows = summaries.map((summary) => _formatExcelRow(summary)).toList(); + + for (int i = 0; i < rows.length; i++) { + final row = rows[i]; + for (int j = 0; j < row.length; j++) { + final cell = row[j]; + sheet + .cell(CellIndex.indexByColumnRow(rowIndex: i + 2, columnIndex: j)) + .value = cell; + } + } + return excel.encode() ?? []; + } + + String _getCSVFileContent( + List summaries, + ) { + final List> rows = []; + final headerRow = []; + for (final key in AnalyticsSummaryEnum.values) { + headerRow.add(key.header(L10n.of(context))); + } + rows.add(headerRow); + + for (final summary in summaries) { + final row = []; + for (int i = 0; i < AnalyticsSummaryEnum.values.length; i++) { + final key = AnalyticsSummaryEnum.values[i]; + final value = summary.getValue(key); + value is List ? row.add(value.join(", ")) : row.add(value); + } + rows.add(row); + } + + final String fileString = const ListToCsvConverter().convert(rows); + return fileString; + } + + String getCopy(ConstructUses use) { + return getGrammarCopy( + category: use.category, + lemma: use.lemma, + context: context, + ) ?? + 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( + child: Container( + constraints: const BoxConstraints( + maxWidth: 400, + ), + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).fileType, + style: TextStyle( + fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SegmentedButton( + selected: {_downloadType}, + onSelectionChanged: (c) => _setDownloadType(c.first), + segments: [ + ButtonSegment( + value: DownloadType.csv, + label: Text(L10n.of(context).commaSeparatedFile), + ), + ButtonSegment( + value: DownloadType.xlsx, + label: Text(L10n.of(context).excelFile), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 300, + minHeight: 0, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: _usersToDownload.length, + itemBuilder: (context, index) { + final user = _usersToDownload[index]; + final analyticsAvailable = + _userAnalyticsRoom(user.id) != null; + + String tooltip = ""; + if (!analyticsAvailable) { + tooltip = L10n.of(context).analyticsNotAvailable; + } else if (_downloadStatues[user.id] == -1) { + tooltip = L10n.of(context).failedFetchUserAnalytics; + } + + return Padding( + padding: const EdgeInsets.all(4.0), + child: Tooltip( + message: tooltip, + triggerMode: TooltipTriggerMode.tap, + child: Opacity( + opacity: analyticsAvailable && + _downloadStatues[user.id] != -1 + ? 1 + : 0.5, + child: Row( + children: [ + SizedBox( + width: 30, + child: !analyticsAvailable + ? const Icon( + Icons.error_outline, + size: 16, + ) + : Center( + child: AnimatedContainer( + duration: + FluffyThemes.animationDuration, + height: 12, + width: 12, + decoration: BoxDecoration( + color: + _downloadStatusColor(user.id), + borderRadius: + BorderRadius.circular(100), + ), + ), + ), + ), + Flexible( + child: Text(user.displayName ?? user.id), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0), + child: OutlinedButton( + onPressed: _loading || !_initialized ? null : _runDownload, + child: _initialized + ? Text( + _loading + ? L10n.of(context).downloading + : L10n.of(context).download, + ) + : const SizedBox( + height: 10, + width: 10, + child: CircularProgressIndicator.adaptive(), + ), + ), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _finishedDownload + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context).downloadComplete), + ) + : const SizedBox(), + ), + AnimatedSize( + duration: FluffyThemes.animationDuration, + child: _error != null + ? Padding( + padding: const EdgeInsets.all(8.0), + child: Text(L10n.of(context).oopsSomethingWentWrong), + ) + : const SizedBox(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat_settings_popup_menu.dart b/lib/widgets/chat_settings_popup_menu.dart index ea10dffac..a016b130f 100644 --- a/lib/widgets/chat_settings_popup_menu.dart +++ b/lib/widgets/chat_settings_popup_menu.dart @@ -9,6 +9,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart'; import 'package:fluffychat/pangea/utils/download_chat.dart'; +import 'package:fluffychat/pangea/utils/download_file.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'matrix.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index ecf049362..ca39ec934 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.5+6 +version: 4.1.5+13 environment: sdk: ">=3.0.0 <4.0.0"