1370 space analytics download (#1381)

* fix: reassign eventID metadata when turning locally saved draft uses into locally saved uses with assosiated eventID

* feat: initial work for space analytics download

* feat: updated spreadsheet columns in space analytics download

* feat: move space analytics download logic to widget

* feat: improved download loading UI

* feat: added error logging to space analytics download dialog
This commit is contained in:
ggurdin 2025-01-08 15:24:26 -05:00 committed by GitHub
parent 7b1cccf78b
commit 26285eab52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1266 additions and 184 deletions

View file

@ -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!"
}

View file

@ -23,6 +23,7 @@ import 'package:fluffychat/pangea/utils/error_handler.dart';
class GetAnalyticsController {
late PangeaController _pangeaController;
late MessageAnalyticsController perMessage;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
StreamController<AnalyticsStreamUpdate> analyticsStream =

View file

@ -247,6 +247,17 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
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);

View file

@ -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;
}
}
}

View file

@ -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 {

View file

@ -85,16 +85,25 @@ extension AnalyticsClientExtension on Client {
// so they will appear in the space hierarchy
Future<void> _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,
);
}

View file

@ -152,7 +152,8 @@ extension AnalyticsRoomExtension on Room {
}
Future<DateTime?> _analyticsLastUpdated(String userId) async {
final List<Event> events = await getRoomAnalyticsEvents(count: 1);
final List<Event> 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<ConstructAnalyticsEvent> analyticsEvents = [];
for (final Event event in events) {
analyticsEvents.add(ConstructAnalyticsEvent(event: event));

View file

@ -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<String> listLemmas;
// List<String> listLemmasUsedCorrectly;
// List<String> listLemmasUsedIncorrectly;
/// 0 - 30 XP
int numLemmasSmallXP;
// List<String> listLemmasSmallXP;
/// 31 - 200 XP
int numLemmasMediumXP;
// List<String> listLemmasMediumXP;
/// > 200 XP
int numLemmasLargeXP;
// List<String> listLemmasLargeXP;
int numMorphConstructs;
List<String> listMorphConstructs;
List<String> listMorphConstructsUsedCorrectly;
List<String> listMorphConstructsUsedIncorrectly;
// list morph 0 - 30 XP
List<String> listMorphSmallXP;
// list morph 31 - 200 XP
List<String> listMorphMediumXP;
// list morph 200 - 500 XP
List<String> listMorphLargeXP;
// list morph > 500 XP
List<String> 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<String, dynamic> 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,
};
}
}

View file

@ -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<OneConstructUse> _uses = [];
final List<OneConstructUse> _uses = [];
List<OneConstructUse> get uses => _uses;
List<OneConstructUse> 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<String, List<ConstructUses>> _categoriesToUses = {};
/// A list of unique vocab lemmas
List<String> vocabLemmasList = [];
/// A list of unique grammar lemmas
List<String> 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<OneConstructUse> 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<String> 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<OneConstructUse> newUses) {
@ -81,7 +86,6 @@ class ConstructListModel {
void _updateUsesList(List<OneConstructUse> 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<int>(
@ -205,9 +209,7 @@ class ConstructListModel {
List<ConstructUses> 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<String> morphActivityDistractors(
String morphFeature,
String morphTag,
) {
final List<ConstructUses> morphConstructs = constructList(
type: ConstructTypeEnum.morph,
);
final List<String> 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<List<String>> lemmaActivityDistractors(PangeaToken token) async {
final List<String> lemmas = constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
// Offload computation to an isolate
final Map<String, int> 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<String, List<ConstructUses>> lemmasToUses({
ConstructTypeEnum? type,
}) {
final Map<String, List<ConstructUses>> 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<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
final List<String> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> 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<List<int>> 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<String, List<ConstructUses>> 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<String> correctLemmas = [];
final List<String> 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<String> overLemmas = [];
final List<String> 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<int>(
0,
(total, use) => total + use.points,
);
}
List<String> 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<String> over;
final List<String> under;
LemmasOverUnderList({
required this.over,
required this.under,
});
}

View file

@ -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,

View file

@ -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<bool> 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<String> morphActivityDistractors(
String morphFeature,
String morphTag,
) {
final List<ConstructUses> morphConstructs = MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.morph);
final List<String> 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<List<String>> lemmaActivityDistractors(PangeaToken token) async {
final List<String> lemmas = MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
// Offload computation to an isolate
final Map<String, int> 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<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
final List<String> lemmas = params['lemmas'];
final String target = params['target'];
// Calculate Levenshtein distances
final Map<String, int> 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<List<int>> 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];
}
}

View file

@ -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(

View file

@ -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,
),
),
),
],
);
}
}

View file

@ -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<MessageActivityResponse> get(
@ -19,9 +18,7 @@ class LemmaActivityGenerator {
debugger(when: kDebugMode && req.targetTokens.length != 1);
final token = req.targetTokens.first;
final List<String> choices = await MatrixState
.pangeaController.getAnalytics.constructListModel
.lemmaActivityDistractors(token);
final List<String> choices = await token.lemmaActivityDistractors(token);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(

View file

@ -75,9 +75,8 @@ class MorphActivityGenerator {
throw "No morph tag found for morph feature";
}
final List<String> distractors = MatrixState
.pangeaController.getAnalytics.constructListModel
.morphActivityDistractors(morphFeature, morphTag);
final List<String> distractors =
token.morphActivityDistractors(morphFeature, morphTag);
return MessageActivityResponse(
activity: PracticeActivityModel(

View file

@ -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<void> downloadChat(
Room room,
DownloadType type,
@ -192,52 +185,6 @@ String mimetype(DownloadType fileType) {
}
}
Future<void> 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<PangeaMessageEvent> messages,
BuildContext context,

View file

@ -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<void> 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);
}
}
}

View file

@ -86,7 +86,9 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
),
Expanded(
child: ConstructsTileList(
_categoriesToUses[selectedCategory]!,
_categoriesToUses[selectedCategory]!
.where((use) => use.points > 0)
.toList(),
),
),
],
@ -95,7 +97,10 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
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(

View file

@ -22,7 +22,7 @@ class LevelBarPopup extends StatelessWidget {
int get totalXP => getAnalyticsController.constructListModel.totalXP;
int get maxLevelXP => getAnalyticsController.minXPForNextLevel;
List<OneConstructUse> get uses =>
getAnalyticsController.constructListModel.uses;
getAnalyticsController.constructListModel.truncatedUses;
@override
Widget build(BuildContext context) {

View file

@ -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<DownloadAnalyticsDialog> {
bool _initialized = false;
bool _loading = false;
bool _finishedDownload = false;
String? _error;
Map<String, int> _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 <User>[];
});
}
List<User> 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<void> _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<void> _downloadSpaceAnalytics() async {
final l2 = MatrixState.pangeaController.languageController.userL2?.langCode;
if (l2 == null) return;
final List<AnalyticsSummaryModel?> summaries = await Future.wait(
_usersToDownload.map((user) => _getUserAnalyticsModel(user.id)),
);
final allSummaries = summaries.whereType<AnalyticsSummaryModel>().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<AnalyticsSummaryModel?> _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<OneConstructUse> 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<CellValue> _formatExcelRow(
AnalyticsSummaryModel summary,
) {
final List<CellValue> 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<String>) {
row.add(TextCellValue(value.join(", ")));
}
}
return row;
}
List<int> _getExcelFileContent(
List<AnalyticsSummaryModel> 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<AnalyticsSummaryModel> summaries,
) {
final List<List<dynamic>> 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<String> ? 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<DownloadType>(
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(),
),
],
),
),
);
}
}

View file

@ -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';

View file

@ -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"