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:
parent
7b1cccf78b
commit
26285eab52
22 changed files with 1266 additions and 184 deletions
|
|
@ -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!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
108
lib/pangea/enum/analytics/analytics_summary_enum.dart
Normal file
108
lib/pangea/enum/analytics/analytics_summary_enum.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
259
lib/pangea/models/analytics/analytics_summary_model.dart
Normal file
259
lib/pangea/models/analytics/analytics_summary_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
59
lib/pangea/utils/download_file.dart
Normal file
59
lib/pangea/utils/download_file.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
417
lib/pangea/widgets/download_analytics_dialog.dart
Normal file
417
lib/pangea/widgets/download_analytics_dialog.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue