feat: add language indicators to analytics database entries (#5692)

* feat: add language indicators to analytics database entries

* better handling for database reinit

* don't clear database is last update not set
This commit is contained in:
ggurdin 2026-02-13 15:06:21 -05:00 committed by GitHub
parent 1de440156c
commit 3537cd5fd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 524 additions and 198 deletions

View file

@ -2227,8 +2227,9 @@ class ChatController extends State<ChatPageWithRoom>
),
];
_showAnalyticsFeedback(constructs, eventId);
addAnalytics(constructs, eventId);
final langCode = originalSent.langCode.split('-').first;
_showAnalyticsFeedback(constructs, eventId, langCode);
addAnalytics(constructs, eventId, langCode);
}
}
@ -2241,10 +2242,13 @@ class ChatController extends State<ChatPageWithRoom>
final constructs = stt.constructs(roomId, eventId);
if (constructs.isEmpty) return;
_showAnalyticsFeedback(constructs, eventId);
Matrix.of(
context,
).analyticsDataService.updateService.addAnalytics(eventId, constructs);
final langCode = stt.langCode.split('-').first;
_showAnalyticsFeedback(constructs, eventId, langCode);
Matrix.of(context).analyticsDataService.updateService.addAnalytics(
eventId,
constructs,
langCode,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
@ -2398,16 +2402,19 @@ class ChatController extends State<ChatPageWithRoom>
Future<void> _showAnalyticsFeedback(
List<OneConstructUse> constructs,
String eventId,
String language,
) async {
final analyticsService = Matrix.of(context).analyticsDataService;
final newGrammarConstructs = await analyticsService.getNewConstructCount(
constructs,
ConstructTypeEnum.morph,
language,
);
final newVocabConstructs = await analyticsService.getNewConstructCount(
constructs,
ConstructTypeEnum.vocab,
language,
);
OverlayUtil.showOverlay(

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_analytics_repo.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
@ -103,6 +104,10 @@ class ActivityChatController {
Future<ActivitySummaryAnalyticsModel> getActivityAnalytics() async {
final cached = ActivitySessionAnalyticsRepo.get(room.id);
final analytics = cached?.analytics ?? ActivitySummaryAnalyticsModel();
final activityLang = room.activityPlan?.req.targetLanguage;
if (activityLang == null) {
return analytics;
}
DateTime? timestamp = room.creationTimestamp;
if (cached != null) {
@ -114,6 +119,7 @@ class ActivityChatController {
MatrixState.pangeaController.matrixState.analyticsDataService;
uses = await analyticsService.getUses(
activityLang.split('-').first,
since: timestamp ?? DateTime.fromMillisecondsSinceEpoch(0),
roomId: room.id,
);

View file

@ -95,6 +95,7 @@ class _VocabChipsState extends State<_VocabChips> with TokenRenderingMixin {
"activity_tokens",
widget.targetId,
token,
widget.langCode.split('-').first,
Matrix.of(context).analyticsDataService,
).then((_) {
if (mounted) setState(() {});

View file

@ -121,13 +121,31 @@ class AnalyticsDataService {
}
_invalidateCaches();
final l2 = MatrixState.pangeaController.userController.userL2;
final analyticsUserId = await _analyticsClientGetter.database.getUserID();
final lastUpdated = await _analyticsClientGetter.database
.getLastUpdated();
final analyticsLanguage = await _analyticsClientGetter.database
.getCurrentLanguage();
if (analyticsUserId != client.userID || lastUpdated == null) {
await _clearDatabase();
if (analyticsUserId != client.userID || analyticsLanguage == null) {
// If current language not set, analytics database needs be updated to include language flag, so clear it.
// If user ID doesn't match, this means that a different user has logged in since the last time the database was initialized,
// so clear it to avoid showing another user's analytics.
_clear();
await _analyticsClientGetter.database.clear();
await _analyticsClientGetter.database.updateUserID(client.userID!);
if (l2 != null) {
await _analyticsClientGetter.database.updateCurrentLanguage(
l2.langCodeShort,
);
}
} else if (l2 != null && analyticsLanguage != l2.langCodeShort) {
// If the current language doesn't match the language in the database, this means that
// the user has switched their L2 since the last time the database was initialized.
// Clear local cache / merge table data.
_clear();
await _analyticsClientGetter.database.updateCurrentLanguage(
l2.langCodeShort,
);
}
_syncController?.dispose();
@ -136,21 +154,27 @@ class AnalyticsDataService {
dataService: this,
);
await _syncController!.bulkUpdate();
if (l2 != null) {
await _syncController!.bulkUpdate(l2.langCodeShort);
}
final resp = await client.getUserProfile(client.userID!);
final analyticsProfile = AnalyticsProfileModel.fromJson(
resp.additionalProperties,
);
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 != null) {
await updateXPOffset(analyticsProfile.xpOffsetByLanguage(l2) ?? 0);
await updateXPOffset(
analyticsProfile.xpOffsetByLanguage(l2) ?? 0,
l2.langCodeShort,
);
}
_syncController!.start();
await _initMergeTable();
if (l2 != null) {
await _initMergeTable(l2.langCodeShort);
}
} catch (e, s) {
Logs().e("Error initializing analytics: $e, $s");
} finally {
@ -161,12 +185,14 @@ class AnalyticsDataService {
}
}
Future<void> _initMergeTable() async {
Future<void> _initMergeTable(String language) async {
final vocab = await _analyticsClientGetter.database.getAggregatedConstructs(
ConstructTypeEnum.vocab,
language,
);
final morph = await _analyticsClientGetter.database.getAggregatedConstructs(
ConstructTypeEnum.morph,
language,
);
final blocked = blockedConstructs;
@ -177,12 +203,11 @@ class AnalyticsDataService {
Future<void> reinitialize() async {
Logs().i("Reinitializing analytics database.");
initCompleter = Completer<void>();
await _clearDatabase();
_clear();
await _initDatabase(_analyticsClientGetter.client);
}
Future<void> _clearDatabase() async {
await _analyticsClient?.database.clear();
void _clear() {
_invalidateCaches();
_mergeTable.clear();
}
@ -190,8 +215,7 @@ class AnalyticsDataService {
Future<void> _closeDatabase() async {
await _analyticsClient?.database.delete();
_analyticsClient = null;
_invalidateCaches();
_mergeTable.clear();
_clear();
}
Future<void> _ensureInitialized() =>
@ -217,23 +241,24 @@ class AnalyticsDataService {
DerivedAnalyticsDataModel? get cachedDerivedData => _cachedDerivedStats;
Future<DerivedAnalyticsDataModel> get derivedData async {
Future<DerivedAnalyticsDataModel> derivedData(String language) async {
await _ensureInitialized();
if (_cachedDerivedStats == null || _derivedCacheVersion != _cacheVersion) {
_cachedDerivedStats = await _analyticsClientGetter.database
.getDerivedStats();
.getDerivedStats(language);
_derivedCacheVersion = _cacheVersion;
}
return _cachedDerivedStats!;
}
Future<DateTime?> getLastUpdatedAnalytics() async {
return _analyticsClientGetter.database.getLastEventTimestamp();
Future<DateTime?> getLastUpdatedAnalytics(String language) async {
return _analyticsClientGetter.database.getLastEventTimestamp(language);
}
Future<List<OneConstructUse>> getUses({
Future<List<OneConstructUse>> getUses(
String language, {
int? count,
String? roomId,
DateTime? since,
@ -242,6 +267,7 @@ class AnalyticsDataService {
}) async {
await _ensureInitialized();
final uses = await _analyticsClientGetter.database.getUses(
language,
count: count,
roomId: roomId,
since: since,
@ -257,7 +283,7 @@ class AnalyticsDataService {
if (use.category == 'other') continue;
if (!cappedLastUseCache.containsKey(use.identifier)) {
final constructs = await getConstructUse(use.identifier);
final constructs = await getConstructUse(use.identifier, language);
cappedLastUseCache[use.identifier] = constructs.cappedLastUse;
}
final cappedLastUse = cappedLastUseCache[use.identifier];
@ -271,17 +297,20 @@ class AnalyticsDataService {
return filtered;
}
Future<List<OneConstructUse>> getLocalUses() async {
Future<List<OneConstructUse>> getLocalUses(String language) async {
await _ensureInitialized();
return _analyticsClientGetter.database.getLocalUses();
return _analyticsClientGetter.database.getLocalUses(language);
}
Future<int> getLocalConstructCount() async {
Future<int> getLocalConstructCount(String language) async {
await _ensureInitialized();
return _analyticsClientGetter.database.getLocalConstructCount();
return _analyticsClientGetter.database.getLocalConstructCount(language);
}
Future<ConstructUses> getConstructUse(ConstructIdentifier id) async {
Future<ConstructUses> getConstructUse(
ConstructIdentifier id,
String language,
) async {
await _ensureInitialized();
final blocked = blockedConstructs;
final ids = _mergeTable.groupedIds(_mergeTable.resolve(id), blocked);
@ -294,11 +323,12 @@ class AnalyticsDataService {
);
}
return _analyticsClientGetter.database.getConstructUse(ids);
return _analyticsClientGetter.database.getConstructUse(ids, language);
}
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
List<ConstructIdentifier> ids,
String language,
) async {
await _ensureInitialized();
final Map<ConstructIdentifier, List<ConstructIdentifier>> request = {};
@ -308,14 +338,15 @@ class AnalyticsDataService {
request[id] = _mergeTable.groupedIds(_mergeTable.resolve(id), blocked);
}
return _analyticsClientGetter.database.getConstructUses(request);
return _analyticsClientGetter.database.getConstructUses(request, language);
}
Future<Map<ConstructIdentifier, ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
String language,
) async {
final combined = await _analyticsClientGetter.database
.getAggregatedConstructs(type);
.getAggregatedConstructs(type, language);
final stopwatch = Stopwatch()..start();
@ -345,6 +376,7 @@ class AnalyticsDataService {
Future<int> getNewConstructCount(
List<OneConstructUse> newConstructs,
ConstructTypeEnum type,
String language,
) async {
await _ensureInitialized();
final blocked = blockedConstructs;
@ -364,7 +396,10 @@ class AnalyticsDataService {
constructPoints[use.identifier]! + use.xp;
}
final constructs = await getConstructUses(constructPoints.keys.toList());
final constructs = await getConstructUses(
constructPoints.keys.toList(),
language,
);
int newConstructCount = 0;
for (final entry in constructPoints.entries) {
@ -377,13 +412,14 @@ class AnalyticsDataService {
return newConstructCount;
}
Future<void> updateXPOffset(int offset) async {
Future<void> updateXPOffset(int offset, String language) async {
_invalidateCaches();
await _analyticsClientGetter.database.updateXPOffset(offset);
await _analyticsClientGetter.database.updateXPOffset(offset, language);
}
Future<List<AnalyticsUpdateEvent>> updateLocalAnalytics(
AnalyticsUpdate update,
String language,
) async {
final events = <AnalyticsUpdateEvent>[];
final addedConstructs = update.addedConstructs
@ -391,8 +427,8 @@ class AnalyticsDataService {
.toList();
final updateIds = addedConstructs.map((c) => c.identifier).toList();
final prevData = await derivedData;
final prevConstructs = await getConstructUses(updateIds);
final prevData = await derivedData(language);
final prevConstructs = await getConstructUses(updateIds, language);
_invalidateCaches();
await _ensureInitialized();
@ -403,9 +439,12 @@ class AnalyticsDataService {
.toSet();
_mergeTable.addConstructsByUses(addedConstructs, blocked);
await _analyticsClientGetter.database.updateLocalAnalytics(addedConstructs);
await _analyticsClientGetter.database.updateLocalAnalytics(
addedConstructs,
language,
);
final newConstructs = await getConstructUses(updateIds);
final newConstructs = await getConstructUses(updateIds, language);
int points = 0;
if (updateIds.isNotEmpty) {
@ -418,7 +457,7 @@ class AnalyticsDataService {
}
final newData = prevData.addXP(points);
await _analyticsClientGetter.database.updateDerivedStats(newData);
await _analyticsClientGetter.database.updateDerivedStats(newData, language);
// Update public profile each time that new analytics are added.
// If the level hasn't changed, this will not send an update to the server.
@ -444,6 +483,7 @@ class AnalyticsDataService {
.publicProfile!
.analytics
.xpOffset!,
language,
);
}
@ -478,30 +518,43 @@ class AnalyticsDataService {
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
String language,
) async {
_invalidateCaches();
final blocked = blockedConstructs;
for (final event in events) {
_mergeTable.addConstructsByUses(event.content.uses, blocked);
}
await _analyticsClientGetter.database.updateServerAnalytics(events);
final vocab = await getAggregatedConstructs(ConstructTypeEnum.vocab);
final morphs = await getAggregatedConstructs(ConstructTypeEnum.morph);
await _analyticsClientGetter.database.updateServerAnalytics(
events,
language,
);
final vocab = await getAggregatedConstructs(
ConstructTypeEnum.vocab,
language,
);
final morphs = await getAggregatedConstructs(
ConstructTypeEnum.morph,
language,
);
final constructs = [...vocab.values, ...morphs.values];
final totalXP = constructs.fold(0, (total, c) => total + c.points);
await _analyticsClientGetter.database.updateTotalXP(totalXP);
await _analyticsClientGetter.database.updateTotalXP(totalXP, language);
}
Future<void> updateBlockedConstructs(ConstructIdentifier constructId) async {
Future<void> updateBlockedConstructs(
ConstructIdentifier constructId,
String language,
) async {
await _ensureInitialized();
_mergeTable.removeConstruct(constructId);
final construct = await _analyticsClientGetter.database.getConstructUse([
constructId,
]);
], language);
final derived = await derivedData;
final derived = await derivedData(language);
final newXP = derived.totalXP - construct.points;
final newLevel = DerivedAnalyticsDataModel.calculateLevelWithXp(newXP);
@ -509,13 +562,13 @@ class AnalyticsDataService {
level: newLevel,
);
await _analyticsClientGetter.database.updateTotalXP(newXP);
await _analyticsClientGetter.database.updateTotalXP(newXP, language);
_invalidateCaches();
}
Future<void> clearLocalAnalytics() async {
Future<void> clearLocalAnalytics(String language) async {
_invalidateCaches();
await _ensureInitialized();
await _analyticsClientGetter.database.clearLocalConstructData();
await _analyticsClientGetter.database.clearLocalConstructData(language);
}
}

View file

@ -3,6 +3,7 @@
import 'dart:async';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common/sqflite.dart';
import 'package:synchronized/synchronized.dart';
@ -170,30 +171,42 @@ class AnalyticsDatabase with DatabaseFileStorage {
(ConstructTypeEnum.morph, false) => _aggregatedServerMorphConstructsBox,
};
String _langKey(String key, String language) => '$language|$key';
bool _isLanguageKey(String key, String language) =>
key.startsWith('$language|');
Future<String?> getUserID() => _lastEventTimestampBox.get('user_id');
Future<DateTime?> getLastUpdated() async {
final entry = await _lastEventTimestampBox.get('last_updated');
Future<DateTime?> getLastUpdated(String language) async {
final entry = await _lastEventTimestampBox.get(
_langKey('last_updated', language),
);
if (entry == null) return null;
return DateTime.tryParse(entry);
}
Future<DateTime?> getLastEventTimestamp() async {
Future<DateTime?> getLastEventTimestamp(String language) async {
final timestampString = await _lastEventTimestampBox.get(
'last_event_timestamp',
_langKey('last_event_timestamp', language),
);
if (timestampString == null) return null;
return DateTime.parse(timestampString);
}
Future<DerivedAnalyticsDataModel> getDerivedStats() async {
final raw = await _derivedStatsBox.get('derived_stats');
Future<String?> getCurrentLanguage() async {
return _lastEventTimestampBox.get('current_language');
}
Future<DerivedAnalyticsDataModel> getDerivedStats(String language) async {
final raw = await _derivedStatsBox.get(_langKey('derived_stats', language));
return raw == null
? DerivedAnalyticsDataModel()
: DerivedAnalyticsDataModel.fromJson(Map<String, dynamic>.from(raw));
}
Future<List<OneConstructUse>> getUses({
Future<List<OneConstructUse>> getUses(
String language, {
int? count,
String? roomId,
DateTime? since,
@ -218,7 +231,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
}
// ---- Local uses ----
final localUses = await getLocalUses()
final localUses = await getLocalUses(language)
..sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
for (final use in localUses) {
@ -232,11 +245,18 @@ class AnalyticsDatabase with DatabaseFileStorage {
}
// ---- Server uses ----
final serverKeys = await _serverConstructsBox.getAllKeys()
..sort(
(a, b) =>
int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])),
);
final serverKeys = (await _serverConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
// Filter out malformed or legacy keys that don't have a timestamp
.where((key) {
final parts = key.split('|');
return parts.length >= 3 && int.tryParse(parts[2]) != null;
})
.sorted((a, b) {
final aTimestamp = int.parse(a.split('|')[2]);
final bTimestamp = int.parse(b.split('|')[2]);
return bTimestamp.compareTo(aTimestamp);
});
for (final key in serverKeys) {
final serverUses = await getServerUses(key)
@ -254,9 +274,12 @@ class AnalyticsDatabase with DatabaseFileStorage {
return results;
}
Future<List<OneConstructUse>> getLocalUses() async {
Future<List<OneConstructUse>> getLocalUses(String language) async {
final List<OneConstructUse> uses = [];
final localKeys = await _localConstructsBox.getAllKeys();
final localKeys = (await _localConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
.toList();
final localValues = await _localConstructsBox.getAll(localKeys);
for (final rawList in localValues) {
if (rawList == null) continue;
@ -279,24 +302,17 @@ class AnalyticsDatabase with DatabaseFileStorage {
return uses;
}
Future<int> getLocalConstructCount() async {
final keys = await _localConstructsBox.getAllKeys();
Future<int> getLocalConstructCount(String language) async {
final keys = (await _localConstructsBox.getAllKeys()).where(
(key) => _isLanguageKey(key, language),
);
return keys.length;
}
Future<List<String>> getVocabConstructKeys() async {
final serverKeys = await _aggregatedServerVocabConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalVocabConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<List<String>> getMorphConstructKeys() async {
final serverKeys = await _aggregatedServerMorphConstructsBox.getAllKeys();
final localKeys = await _aggregatedLocalMorphConstructsBox.getAllKeys();
return [...serverKeys, ...localKeys];
}
Future<ConstructUses> getConstructUse(List<ConstructIdentifier> ids) async {
Future<ConstructUses> getConstructUse(
List<ConstructIdentifier> ids,
String language,
) async {
assert(ids.isNotEmpty);
final ConstructUses construct = ConstructUses(
@ -315,12 +331,12 @@ class AnalyticsDatabase with DatabaseFileStorage {
final serverBox = _aggBox(id.type, false);
final localBox = _aggBox(id.type, true);
final serverRaw = await serverBox.get(key);
final serverRaw = await serverBox.get(_langKey(key, language));
if (serverRaw != null) {
server = ConstructUses.fromJson(Map<String, dynamic>.from(serverRaw));
}
final localRaw = await localBox.get(key);
final localRaw = await localBox.get(_langKey(key, language));
if (localRaw != null) {
local = ConstructUses.fromJson(Map<String, dynamic>.from(localRaw));
}
@ -333,28 +349,46 @@ class AnalyticsDatabase with DatabaseFileStorage {
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
Map<ConstructIdentifier, List<ConstructIdentifier>> ids,
String language,
) async {
final Map<ConstructIdentifier, ConstructUses> results = {};
for (final entry in ids.entries) {
final construct = await getConstructUse(entry.value);
final construct = await getConstructUse(entry.value, language);
results[entry.key] = construct;
}
return results;
}
Future<void> clearLocalConstructData() async {
Future<void> clearLocalConstructData(String language) async {
await _transaction(() async {
await _localConstructsBox.clear();
await _aggregatedLocalVocabConstructsBox.clear();
await _aggregatedLocalMorphConstructsBox.clear();
final localKeys = (await _localConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
.toList();
final localVocabAggKeys =
(await _aggregatedLocalVocabConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
.toList();
final localMorphAggKeys =
(await _aggregatedLocalMorphConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
.toList();
await _localConstructsBox.deleteAll(localKeys);
await _aggregatedLocalVocabConstructsBox.deleteAll(localVocabAggKeys);
await _aggregatedLocalMorphConstructsBox.deleteAll(localMorphAggKeys);
});
}
/// Group uses by aggregate key
Map<String, List<OneConstructUse>> _groupUses(List<OneConstructUse> uses) {
Map<String, List<OneConstructUse>> _groupUses(
List<OneConstructUse> uses,
String language,
) {
final Map<String, List<OneConstructUse>> grouped = {};
for (final u in uses) {
final key = u.identifier.storageKey;
final key = _langKey(u.identifier.storageKey, language);
(grouped[key] ??= []).add(u);
}
return grouped;
@ -405,12 +439,19 @@ class AnalyticsDatabase with DatabaseFileStorage {
Future<List<ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
String language,
) async {
Map<String, ConstructUses> combined = {};
final stopwatch = Stopwatch()..start();
final localKeys = await _aggBox(type, true).getAllKeys();
final serverKeys = await _aggBox(type, false).getAllKeys();
final localKeys = (await _aggBox(
type,
true,
).getAllKeys()).where((key) => _isLanguageKey(key, language)).toList();
final serverKeys = (await _aggBox(
type,
false,
).getAllKeys()).where((key) => _isLanguageKey(key, language)).toList();
final serverValues = await _aggBox(type, false).getAll(serverKeys);
final serverConstructs = serverValues
@ -456,45 +497,65 @@ class AnalyticsDatabase with DatabaseFileStorage {
});
}
Future<void> updateLastUpdated(DateTime timestamp) {
Future<void> updateCurrentLanguage(String language) {
return _transaction(() async {
await _lastEventTimestampBox.put('current_language', language);
});
}
Future<void> _updateLastUpdated(DateTime timestamp, String language) async {
return _transaction(() async {
await _lastEventTimestampBox.put(
'last_updated',
_langKey('last_updated', language),
timestamp.toIso8601String(),
);
});
}
Future<void> updateXPOffset(int offset) {
Future<void> updateXPOffset(int offset, String language) async {
return _transaction(() async {
final stats = await getDerivedStats();
final stats = await getDerivedStats(language);
final updatedStats = stats.copyWithOffset(offset);
await _derivedStatsBox.put('derived_stats', updatedStats.toJson());
await _derivedStatsBox.put(
_langKey('derived_stats', language),
updatedStats.toJson(),
);
});
}
Future<void> updateTotalXP(int totalXP) {
Future<void> updateTotalXP(int totalXP, String language) {
return _transaction(() async {
final stats = await getDerivedStats();
final stats = await getDerivedStats(language);
final updatedStats = stats.copyWithTotalXP(totalXP);
await _derivedStatsBox.put('derived_stats', updatedStats.toJson());
await _derivedStatsBox.put(
_langKey('derived_stats', language),
updatedStats.toJson(),
);
});
}
Future<void> updateDerivedStats(DerivedAnalyticsDataModel newStats) =>
_derivedStatsBox.put('derived_stats', newStats.toJson());
Future<void> updateDerivedStats(
DerivedAnalyticsDataModel newStats,
String language,
) => _derivedStatsBox.put(
_langKey('derived_stats', language),
newStats.toJson(),
);
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
String language,
) async {
if (events.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
final lastUpdated = await getLastEventTimestamp();
final lastUpdated = await getLastEventTimestamp(language);
DateTime mostRecent = lastUpdated ?? events.first.event.originServerTs;
final existingKeys = (await _serverConstructsBox.getAllKeys()).toSet();
final existingKeys = (await _serverConstructsBox.getAllKeys())
.where((key) => _isLanguageKey(key, language))
.toSet();
final List<OneConstructUse> aggregatedVocabUses = [];
final List<OneConstructUse> aggregatedMorphUses = [];
@ -508,7 +569,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
).toString();
if (lastUpdated != null && ts.isBefore(lastUpdated)) continue;
if (existingKeys.contains(key)) continue;
if (existingKeys.contains(_langKey(key, language))) continue;
if (ts.isAfter(mostRecent)) mostRecent = ts;
@ -525,7 +586,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
// Write events sequentially
for (final e in pendingWrites.entries) {
_serverConstructsBox.put(
e.key,
_langKey(e.key, language),
e.value.map((u) => u.toJson()).toList(),
);
}
@ -533,7 +594,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedServerVocabConstructsBox,
_groupUses(aggregatedVocabUses),
_groupUses(aggregatedVocabUses, language),
);
for (final entry in aggVocabUpdates.entries) {
@ -545,7 +606,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedServerMorphConstructsBox,
_groupUses(aggregatedMorphUses),
_groupUses(aggregatedMorphUses, language),
);
for (final entry in aggMorphUpdates.entries) {
@ -557,12 +618,12 @@ class AnalyticsDatabase with DatabaseFileStorage {
// Update timestamp
await _lastEventTimestampBox.put(
'last_event_timestamp',
_langKey('last_event_timestamp', language),
mostRecent.toIso8601String(),
);
});
await updateLastUpdated(DateTime.now());
await _updateLastUpdated(DateTime.now(), language);
stopwatch.stop();
Logs().i(
@ -570,7 +631,10 @@ class AnalyticsDatabase with DatabaseFileStorage {
);
}
Future<void> updateLocalAnalytics(List<OneConstructUse> uses) async {
Future<void> updateLocalAnalytics(
List<OneConstructUse> uses,
String language,
) async {
if (uses.isEmpty) return;
final stopwatch = Stopwatch()..start();
@ -578,7 +642,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
// Store local constructs
final key = DateTime.now().millisecondsSinceEpoch;
_localConstructsBox.put(
key.toString(),
_langKey(key.toString(), language),
uses.map((u) => u.toJson()).toList(),
);
@ -593,7 +657,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedLocalVocabConstructsBox,
_groupUses(vocabUses),
_groupUses(vocabUses, language),
);
for (final entry in aggVocabUpdates.entries) {
@ -605,7 +669,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedLocalMorphConstructsBox,
_groupUses(morphUses),
_groupUses(morphUses, language),
);
for (final entry in aggMorphUpdates.entries) {
@ -616,7 +680,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
}
});
await updateLastUpdated(DateTime.now());
await _updateLastUpdated(DateTime.now(), language);
stopwatch.stop();
Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms");

View file

@ -10,6 +10,7 @@ import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -41,6 +42,8 @@ class AnalyticsSyncController {
AnalyticsSyncController({required this.client, required this.dataService});
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
void start() {
_subscription ??= client.onSync.stream.listen(_onSync);
}
@ -52,13 +55,19 @@ class AnalyticsSyncController {
Future<void> _onSync(SyncUpdate update) async {
final analyticsRoom = _getAnalyticsRoom();
if (analyticsRoom == null) return;
final l2 = _l2;
if (analyticsRoom == null || l2 == null) return;
final roomUpdates = update.rooms?.join?[analyticsRoom.id]?.timeline?.events;
if (roomUpdates == null) return;
for (final type in _AnalyticsUpdateEvent.values) {
await _dispatchSyncEvents(type, roomUpdates, analyticsRoom);
await _dispatchSyncEvents(
type,
roomUpdates,
analyticsRoom,
l2.langCodeShort,
);
}
}
@ -66,6 +75,7 @@ class AnalyticsSyncController {
_AnalyticsUpdateEvent type,
List<MatrixEvent> events,
Room analyticsRoom,
String language,
) async {
final updates = events
.where((e) => e.type == type.eventType && e.senderId == client.userID)
@ -73,7 +83,7 @@ class AnalyticsSyncController {
switch (type) {
case _AnalyticsUpdateEvent.constructAnalytics:
await _onConstructEvents(updates, analyticsRoom);
await _onConstructEvents(updates, analyticsRoom, language);
break;
case _AnalyticsUpdateEvent.activityAnalytics:
_onActivityEvents(updates);
@ -82,7 +92,7 @@ class AnalyticsSyncController {
_onLemmaInfoEvents(updates);
break;
case _AnalyticsUpdateEvent.blockedConstruct:
await _onBlockedConstructEvents(updates);
await _onBlockedConstructEvents(updates, language);
break;
}
}
@ -90,6 +100,7 @@ class AnalyticsSyncController {
Future<void> _onConstructEvents(
List<MatrixEvent> events,
Room analyticsRoom,
String language,
) async {
final constructEvents = events
.map(
@ -103,6 +114,7 @@ class AnalyticsSyncController {
if (constructEvents.isEmpty) return;
await dataService.updateDispatcher.sendServerAnalyticsUpdate(
constructEvents,
language,
);
}
@ -139,7 +151,10 @@ class AnalyticsSyncController {
}
}
Future<void> _onBlockedConstructEvents(List<MatrixEvent> events) async {
Future<void> _onBlockedConstructEvents(
List<MatrixEvent> events,
String language,
) async {
for (final event in events) {
final current = AnalyticsSettingsModel.fromJson(event.content);
final prevContent =
@ -155,6 +170,7 @@ class AnalyticsSyncController {
if (newlyBlocked.isEmpty) continue;
await dataService.updateDispatcher.sendBlockedConstructsUpdate(
newlyBlocked.toSet(),
language,
);
}
}
@ -176,11 +192,11 @@ class AnalyticsSyncController {
});
}
Future<void> bulkUpdate() async {
Future<void> bulkUpdate(String language) async {
final analyticsRoom = _getAnalyticsRoom();
if (analyticsRoom == null) return;
final lastUpdated = await dataService.getLastUpdatedAnalytics();
final lastUpdated = await dataService.getLastUpdatedAnalytics(language);
final events = await analyticsRoom.getAnalyticsEvents(
userId: client.userID!,
@ -189,11 +205,11 @@ class AnalyticsSyncController {
if (events == null || events.isEmpty) return;
await dataService.updateServerAnalytics(events);
await dataService.updateServerAnalytics(events, language);
}
Room? _getAnalyticsRoom() {
final l2 = MatrixState.pangeaController.userController.userL2;
final l2 = _l2;
if (l2 == null) return null;
return client.analyticsRoomLocal(l2);
}

View file

@ -87,9 +87,10 @@ class AnalyticsUpdateDispatcher {
Future<void> sendBlockedConstructsUpdate(
Set<ConstructIdentifier> blockedConstructs,
String language,
) async {
for (final blockedConstruct in blockedConstructs) {
await dataService.updateBlockedConstructs(blockedConstruct);
await dataService.updateBlockedConstructs(blockedConstruct, language);
}
final update = AnalyticsStreamUpdate(blockedConstructs: blockedConstructs);
constructUpdateStream.add(update);
@ -101,13 +102,20 @@ class AnalyticsUpdateDispatcher {
Future<void> sendServerAnalyticsUpdate(
List<ConstructAnalyticsEvent> events,
String language,
) async {
await dataService.updateServerAnalytics(events);
await dataService.updateServerAnalytics(events, language);
sendEmptyAnalyticsUpdate();
}
Future<void> sendLocalAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
final events = await dataService.updateLocalAnalytics(analyticsUpdate);
Future<void> sendLocalAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
String language,
) async {
final events = await dataService.updateLocalAnalytics(
analyticsUpdate,
language,
);
for (final event in events) {
_dispatch(event);
}

View file

@ -51,7 +51,7 @@ class AnalyticsUpdateService {
await sendLocalAnalyticsToAnalyticsRoom(l2Override: update.prevTargetLang);
await dataService.reinitialize();
final data = await dataService.derivedData;
final data = await dataService.derivedData(update.targetLang.langCodeShort);
MatrixState.pangeaController.userController.updateAnalyticsProfile(
level: data.level,
);
@ -59,15 +59,19 @@ class AnalyticsUpdateService {
Future<void> addAnalytics(
String? targetID,
List<OneConstructUse> newConstructs, {
List<OneConstructUse> newConstructs,
String language, {
bool forceUpdate = false,
}) async {
await dataService.updateDispatcher.sendLocalAnalyticsUpdate(
AnalyticsUpdate(newConstructs, targetID: targetID),
language,
);
final localConstructCount = await dataService.getLocalConstructCount();
final lastUpdated = await dataService.getLastUpdatedAnalytics();
final localConstructCount = await dataService.getLocalConstructCount(
language,
);
final lastUpdated = await dataService.getLastUpdatedAnalytics(language);
final difference = DateTime.now().difference(lastUpdated ?? DateTime.now());
if (forceUpdate ||
@ -80,6 +84,16 @@ class AnalyticsUpdateService {
Future<void> sendLocalAnalyticsToAnalyticsRoom({
LanguageModel? l2Override,
}) async {
final lang = l2Override ?? _l2;
if (lang == null) {
ErrorHandler.logError(
e: "No L2 language set for user",
m: "Cannot send local analytics to analytics room",
data: {"l2Override": l2Override},
);
return;
}
final inProgress =
_updateCompleter != null && !_updateCompleter!.isCompleted;
@ -90,8 +104,8 @@ class AnalyticsUpdateService {
_updateCompleter = Completer<void>();
try {
await _updateAnalytics(l2Override: l2Override);
await dataService.clearLocalAnalytics();
await _updateAnalytics(lang);
await dataService.clearLocalAnalytics(lang.langCodeShort);
} catch (err, s) {
ErrorHandler.logError(
e: err,
@ -105,13 +119,15 @@ class AnalyticsUpdateService {
}
}
Future<void> _updateAnalytics({LanguageModel? l2Override}) async {
final localConstructs = await dataService.getLocalUses();
Future<void> _updateAnalytics(LanguageModel language) async {
final localConstructs = await dataService.getLocalUses(
language.langCodeShort,
);
if (localConstructs.isEmpty) return;
final analyticsRoom = await _getAnalyticsRoom(l2Override: l2Override);
final analyticsRoom = await _getAnalyticsRoom(l2Override: language);
if (analyticsRoom == null) {
debugPrint(
"No analytics room found for L2 Override: ${l2Override?.langCode}",
"No analytics room found for L2 Override: ${language.langCodeShort}",
);
return;
}

View file

@ -33,9 +33,12 @@ mixin AnalyticsUpdater<T extends StatefulWidget> on State<T> {
Future<void> addAnalytics(
List<OneConstructUse> constructs,
String? targetId,
) => Matrix.of(
context,
).analyticsDataService.updateService.addAnalytics(targetId, constructs);
String language,
) => Matrix.of(context).analyticsDataService.updateService.addAnalytics(
targetId,
constructs,
language,
);
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID != null) {

View file

@ -28,10 +28,18 @@ class LevelUpAnalyticsService {
) async {
await ensureInitialized();
final uses = await dataService.getUses(since: lastLevelUpTimestamp);
final userController = MatrixState.pangeaController.userController;
final l2 = userController.userL2;
if (l2 == null) {
throw Exception("No L2 language set for user");
}
final uses = await dataService.getUses(
l2.langCodeShort,
since: lastLevelUpTimestamp,
);
final messages = await _buildMessageContext(uses);
final userController = MatrixState.pangeaController.userController;
final request = ConstructSummaryRequest(
constructs: uses,
messages: messages,

View file

@ -20,6 +20,7 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart';
import 'package:fluffychat/pangea/morphs/morph_models.dart';
@ -82,8 +83,19 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
super.dispose();
}
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
Future<void> _setAnalyticsData() async {
final future = <Future>[_setMorphs(), _setVocab()];
final l2 = _l2;
if (l2 == null) {
ErrorHandler.logError(
e: "No L2 language set for user",
m: "Cannot set analytics data",
data: {"view": widget.view, "construct": widget.construct},
);
return;
}
final future = <Future>[_setMorphs(), _setVocab(l2.langCodeShort)];
await Future.wait(future);
}
@ -104,11 +116,12 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
}
}
Future<void> _setVocab() async {
Future<void> _setVocab(String language) async {
try {
final analyticsService = Matrix.of(context).analyticsDataService;
final data = await analyticsService.getAggregatedConstructs(
ConstructTypeEnum.vocab,
language,
);
vocab = data.values.toList();
@ -128,11 +141,7 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
morphs = resp;
features = resp.displayFeatures;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"l2": MatrixState.pangeaController.userController.userL2},
);
ErrorHandler.logError(e: e, s: s, data: {"l2": _l2?.langCode});
} finally {
features.sort(
(a, b) => morphFeatureSortOrder
@ -214,9 +223,11 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
PTRequest ptRequest,
PTResponse ptResponse,
) async {
final l2 = _l2;
if (l2 == null) return;
final requestData = TokenInfoFeedbackRequestData(
userId: Matrix.of(context).client.userID!,
detectedLanguage: MatrixState.pangeaController.userController.userL2Code!,
detectedLanguage: l2.langCode,
tokens: [token],
selectedToken: 0,
wordCardL1: MatrixState.pangeaController.userController.userL1Code!,
@ -228,7 +239,7 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
await TokenFeedbackUtil.showTokenFeedbackDialog(
context,
requestData: requestData,
langCode: MatrixState.pangeaController.userController.userL2Code!,
langCode: l2.langCode,
onUpdated: () => reloadNotifier.value++,
);
}

View file

@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
@ -23,12 +24,23 @@ class ConstructXPProgressBar extends StatelessWidget {
);
final analyticsService = Matrix.of(context).analyticsDataService;
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
return StreamBuilder(
stream: analyticsService.updateDispatcher.constructUpdateStream.stream,
builder: (context, snapshot) {
return FutureBuilder(
future: analyticsService.getConstructUse(construct),
future: l2 != null
? analyticsService.getConstructUse(construct, l2)
: Future.value(
ConstructUses(
uses: [],
constructType: construct.type,
lemma: construct.lemma,
category: construct.category,
),
),
builder: (context, snapshot) {
final points = snapshot.data?.points ?? 0;
final progress = min(1.0, points / AnalyticsConstants.xpForFlower);

View file

@ -26,6 +26,8 @@ class MorphAnalyticsListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(vertical: 10.0);
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
return Column(
children: [
@ -54,7 +56,7 @@ class MorphAnalyticsListView extends StatelessWidget {
SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final feature = controller.features[index];
return feature.displayTags.isNotEmpty
return feature.displayTags.isNotEmpty && l2 != null
? Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: MorphFeatureBox(
@ -62,6 +64,7 @@ class MorphAnalyticsListView extends StatelessWidget {
allTags: controller.morphs
.getDisplayTags(feature.feature)
.toSet(),
language: l2,
),
)
: const SizedBox.shrink();
@ -79,11 +82,13 @@ class MorphAnalyticsListView extends StatelessWidget {
class MorphFeatureBox extends StatelessWidget {
final String morphFeature;
final Set<String> allTags;
final String language;
const MorphFeatureBox({
super.key,
required this.morphFeature,
required this.allTags,
required this.language,
});
MorphFeaturesEnum get feature =>
@ -140,7 +145,7 @@ class MorphFeatureBox extends StatelessWidget {
);
return FutureBuilder(
future: analyticsService.getConstructUse(id),
future: analyticsService.getConstructUse(id, language),
builder: (context, snapshot) => MorphTagChip(
morphFeature: morphFeature,
morphTag: morphTag,

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_usage_content.dart';
import 'package:fluffychat/pangea/analytics_details_popup/construct_xp_progress_bar.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_feature_display.dart';
@ -22,10 +23,21 @@ class MorphDetailsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
return FutureBuilder(
future: Matrix.of(
context,
).analyticsDataService.getConstructUse(constructId),
future: l2 != null
? Matrix.of(
context,
).analyticsDataService.getConstructUse(constructId, l2)
: Future.value(
ConstructUses(
uses: [],
lemma: constructId.lemma,
category: constructId.category,
constructType: constructId.type,
),
),
builder: (context, snapshot) {
final construct = snapshot.data;
final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds;

View file

@ -7,6 +7,7 @@ import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popu
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_usage_content.dart';
import 'package:fluffychat/pangea/analytics_details_popup/construct_xp_progress_bar.dart';
import 'package:fluffychat/pangea/analytics_details_popup/word_text_with_audio_button.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -31,9 +32,20 @@ class VocabDetailsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
final analyticsService = Matrix.of(context).analyticsDataService;
return FutureBuilder(
future: analyticsService.getConstructUse(constructId),
future: l2 != null
? analyticsService.getConstructUse(constructId, l2)
: Future.value(
ConstructUses(
uses: [],
constructType: constructId.type,
lemma: constructId.lemma,
category: constructId.category,
),
),
builder: (context, snapshot) {
final construct = snapshot.data;
final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds;

View file

@ -129,9 +129,15 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
}
Future<List<AnalyticsSummaryModel>> _getVocabAnalytics() async {
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 == null) {
throw Exception("No L2 set for user");
}
final analyticsService = Matrix.of(context).analyticsDataService;
final aggregatedVocab = await analyticsService.getAggregatedConstructs(
ConstructTypeEnum.vocab,
l2.langCodeShort,
);
final uses = aggregatedVocab.values.toList();
@ -182,6 +188,10 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
}
Future<List<AnalyticsSummaryModel>> _getMorphAnalytics() async {
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 == null) {
throw Exception("No L2 set for user");
}
final analyticsService = Matrix.of(context).analyticsDataService;
final morphs = await MorphsRepo.get();
@ -199,7 +209,10 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
category: feature.feature,
);
final uses = await analyticsService.getConstructUse(id);
final uses = await analyticsService.getConstructUse(
id,
l2.langCodeShort,
);
final xp = uses.points;
final exampleMessages = await _getExampleMessages([uses]);

View file

@ -28,6 +28,7 @@ mixin LemmaEmojiSetter {
if (constructId.userSetEmoji == null) {
_getEmojiAnalytics(
constructId,
language: langCode.split("-").first,
targetId: targetId,
roomId: roomId,
eventId: eventId,
@ -94,6 +95,7 @@ mixin LemmaEmojiSetter {
void _getEmojiAnalytics(
ConstructIdentifier constructId, {
required String language,
String? eventId,
String? roomId,
String? targetId,
@ -115,6 +117,6 @@ mixin LemmaEmojiSetter {
];
MatrixState.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(targetId, constructs);
.addAnalytics(targetId, constructs, language);
}
}

View file

@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart
import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_popup.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -174,8 +175,13 @@ class LevelUpBannerState extends State<LevelUpBanner>
);
_constructSummaryCompleter.complete(summary);
analyticsRoom.setLevelUpSummary(summary);
} catch (e) {
} catch (e, s) {
debugPrint("Error generating level up analytics: $e");
ErrorHandler.logError(
e: e,
s: s,
data: {"level": widget.level, "prevLevel": widget.prevLevel},
);
_constructSummaryCompleter.completeError(e);
}
}

View file

@ -20,6 +20,7 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dar
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
@ -63,8 +64,12 @@ class SessionLoader extends AsyncLoader<AnalyticsPracticeSessionModel> {
SessionLoader({required this.type});
@override
Future<AnalyticsPracticeSessionModel> fetch() =>
AnalyticsPracticeSessionRepo.get(type);
Future<AnalyticsPracticeSessionModel> fetch() {
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
if (l2 == null) throw Exception('User L2 language not set');
return AnalyticsPracticeSessionRepo.get(type, l2);
}
}
class AnalyticsPractice extends StatefulWidget {
@ -153,6 +158,8 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
AnalyticsDataService get _analyticsService =>
Matrix.of(context).analyticsDataService;
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
List<VocabPracticeChoice> filteredChoices(
MultipleChoicePracticeActivityModel activity,
) {
@ -248,7 +255,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
TtsController.tryToSpeak(
activityTarget.value!.target.tokens.first.vocabConstructID.lemma,
langCode: MatrixState.pangeaController.userController.userL2!.langCode,
langCode: _l2!.langCode,
);
}
@ -324,6 +331,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _analyticsService.updateService.addAnalytics(
null,
bonus,
_l2!.langCodeShort,
forceUpdate: true,
);
AnalyticsPractice.bypassExitConfirmation = true;
@ -534,7 +542,9 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
xp: 0,
);
await _analyticsService.updateService.addAnalytics(null, [use]);
await _analyticsService.updateService.addAnalytics(null, [
use,
], _l2!.langCodeShort);
}
void onHintPressed() {
@ -611,6 +621,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
await _analyticsService.updateService.addAnalytics(
choiceTargetId(choiceContent),
[use],
_l2!.langCodeShort,
);
if (!isCorrect) return;
@ -663,7 +674,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct),
await _analyticsService.getConstructUse(construct, _l2!.langCodeShort),
Matrix.of(context).client,
);
}
@ -677,7 +688,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
Future<DerivedAnalyticsDataModel> get derivedAnalyticsData =>
_analyticsService.derivedData;
_analyticsService.derivedData(_l2!.langCodeShort);
/// Returns congratulations message based on performance
String getCompletionMessage(BuildContext context) {

View file

@ -28,6 +28,7 @@ class InsufficientDataException implements Exception {}
class AnalyticsPracticeSessionRepo {
static Future<AnalyticsPracticeSessionModel> get(
ConstructTypeEnum type,
String language,
) async {
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
false) {
@ -43,12 +44,12 @@ class AnalyticsPracticeSessionRepo {
final halfNeeded = (totalNeeded / 2).ceil();
// Fetch audio constructs (with example messages)
final audioMap = await _fetchAudio();
final audioMap = await _fetchAudio(language);
final audioCount = min(audioMap.length, halfNeeded);
// Fetch vocab constructs to fill the rest
final vocabNeeded = totalNeeded - audioCount;
final vocabConstructs = await _fetchVocab();
final vocabConstructs = await _fetchVocab(language);
final vocabCount = min(vocabConstructs.length, vocabNeeded);
for (final entry in audioMap.entries.take(audioCount)) {
@ -74,12 +75,12 @@ class AnalyticsPracticeSessionRepo {
}
targets.shuffle();
} else {
final errorTargets = await _fetchErrors();
final errorTargets = await _fetchErrors(language);
targets.addAll(errorTargets);
if (targets.length <
(AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize)) {
final morphs = await _fetchMorphs();
final morphs = await _fetchMorphs(language);
final remainingCount =
(AnalyticsPracticeConstants.practiceGroupSize +
AnalyticsPracticeConstants.errorBufferSize) -
@ -118,12 +119,12 @@ class AnalyticsPracticeSessionRepo {
return session;
}
static Future<List<ConstructIdentifier>> _fetchVocab() async {
static Future<List<ConstructIdentifier>> _fetchVocab(String language) async {
final constructs = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
@ -151,13 +152,14 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<Map<ConstructIdentifier, AudioExampleMessage>>
_fetchAudio() async {
static Future<Map<ConstructIdentifier, AudioExampleMessage>> _fetchAudio(
String language,
) async {
final constructs = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
@ -187,7 +189,7 @@ class AnalyticsPracticeSessionRepo {
final audioExampleMessage =
await ExampleMessageUtil.getAudioExampleMessage(
await MatrixState.pangeaController.matrixState.analyticsDataService
.getConstructUse(construct.id),
.getConstructUse(construct.id, language),
MatrixState.pangeaController.matrixState.client,
noBold: true,
);
@ -209,12 +211,12 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<List<MorphPracticeTarget>> _fetchMorphs() async {
static Future<List<MorphPracticeTarget>> _fetchMorphs(String language) async {
final constructs = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.morph)
.getAggregatedConstructs(ConstructTypeEnum.morph, language)
.then((map) => map.values.toList());
final morphInfoRequest = MorphInfoRequest(
@ -288,7 +290,7 @@ class AnalyticsPracticeSessionRepo {
exampleMessage = await ExampleMessageUtil.getExampleMessage(
await MatrixState.pangeaController.matrixState.analyticsDataService
.getConstructUse(entry.id),
.getConstructUse(entry.id, language),
MatrixState.pangeaController.matrixState.client,
form: form,
);
@ -318,12 +320,15 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<List<AnalyticsActivityTarget>> _fetchErrors() async {
static Future<List<AnalyticsActivityTarget>> _fetchErrors(
String language,
) async {
final allRecentUses = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getUses(
language,
count: 300,
filterCapped: false,
types: [

View file

@ -38,6 +38,7 @@ class VocabAudioActivityGenerator {
final choices = await LemmaActivityGenerator.lemmaActivityDistractors(
token,
maxChoices: 20,
language: req.userL2.split('-').first,
);
final choicesList = choices
.map((c) => c.lemma)

View file

@ -8,6 +8,7 @@ class VocabMeaningActivityGenerator {
final token = req.target.tokens.first;
final choices = await LemmaActivityGenerator.lemmaActivityDistractors(
token,
language: req.userL2.split('-').first,
);
if (!choices.contains(token.vocabConstructID)) {

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
@ -199,7 +200,13 @@ class LearningProgressIndicators extends StatelessWidget {
}
: null,
child: FutureBuilder(
future: analyticsService.derivedData,
future: userL2 != null
? analyticsService.derivedData(
userL2.langCodeShort,
)
: Future.value(
DerivedAnalyticsDataModel(),
),
builder: (context, snapshot) {
final cached =
analyticsService.cachedDerivedData;

View file

@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
@ -19,6 +21,8 @@ class LevelAnalyticsDetailsContent extends StatelessWidget {
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
final analyticsService = Matrix.of(context).analyticsDataService;
final language =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
return Scaffold(
body: SafeArea(
@ -35,7 +39,9 @@ class LevelAnalyticsDetailsContent extends StatelessWidget {
canSelect: false,
),
FutureBuilder(
future: analyticsService.derivedData,
future: language != null
? analyticsService.derivedData(language)
: Future.value(DerivedAnalyticsDataModel()),
builder: (context, snapshot) {
if (snapshot.data == null) {
return const SizedBox();
@ -69,8 +75,10 @@ class LevelAnalyticsDetailsContent extends StatelessWidget {
},
),
Expanded(
child: FutureBuilder(
future: analyticsService.getUses(count: 100),
child: FutureBuilder<List<OneConstructUse>>(
future: language != null
? analyticsService.getUses(language, count: 100)
: Future.value(<OneConstructUse>[]),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(

View file

@ -15,7 +15,10 @@ class LemmaActivityGenerator {
debugger(when: kDebugMode && req.target.tokens.length != 1);
final token = req.target.tokens.first;
final choices = await lemmaActivityDistractors(token);
final choices = await lemmaActivityDistractors(
token,
language: req.userL2.split('-').first,
);
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
@ -32,13 +35,14 @@ class LemmaActivityGenerator {
static Future<Set<ConstructIdentifier>> lemmaActivityDistractors(
PangeaToken token, {
required String language,
int? maxChoices = 4,
}) async {
final constructs = await MatrixState
.pangeaController
.matrixState
.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab);
.getAggregatedConstructs(ConstructTypeEnum.vocab, language);
final List<ConstructIdentifier> constructIds = constructs.keys.toList();
// Offload computation to an isolate

View file

@ -66,7 +66,10 @@ class PracticeSelectionRepo {
if (eligibleTokens.isEmpty) {
return PracticeSelection({});
}
final queue = await _fillActivityQueue(eligibleTokens);
final queue = await _fillActivityQueue(
eligibleTokens,
langCode.split('-')[0],
);
final selection = PracticeSelection(queue);
return selection;
}
@ -110,10 +113,11 @@ class PracticeSelectionRepo {
static Future<Map<ActivityTypeEnum, List<PracticeTarget>>> _fillActivityQueue(
List<PangeaToken> tokens,
String language,
) async {
final queue = <ActivityTypeEnum, List<PracticeTarget>>{};
for (final type in ActivityTypeEnum.practiceTypes) {
queue[type] = await _buildActivity(type, tokens);
queue[type] = await _buildActivity(type, tokens, language);
}
return queue;
}
@ -147,9 +151,10 @@ class PracticeSelectionRepo {
static Future<List<PracticeTarget>> _buildActivity(
ActivityTypeEnum activityType,
List<PangeaToken> tokens,
String language,
) async {
if (activityType == ActivityTypeEnum.morphId) {
return _buildMorphActivity(tokens);
return _buildMorphActivity(tokens, language);
}
List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
@ -166,7 +171,11 @@ class PracticeSelectionRepo {
return [];
}
final scores = await _fetchPriorityScores(practiceTokens, activityType);
final scores = await _fetchPriorityScores(
practiceTokens,
activityType,
language,
);
practiceTokens.sort((a, b) => _sortTokens(a, b, scores[a]!, scores[b]!));
practiceTokens = practiceTokens.take(8).toList();
@ -182,12 +191,14 @@ class PracticeSelectionRepo {
static Future<List<PracticeTarget>> _buildMorphActivity(
List<PangeaToken> tokens,
String language,
) async {
final List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
final candidates = practiceTokens.expand(_tokenToMorphTargets).toList();
final scores = await _fetchPriorityScores(
practiceTokens,
ActivityTypeEnum.morphId,
language,
);
candidates.sort(
(a, b) => _sortMorphTargets(
@ -211,6 +222,7 @@ class PracticeSelectionRepo {
static Future<Map<PangeaToken, int>> _fetchPriorityScores(
List<PangeaToken> tokens,
ActivityTypeEnum activityType,
String language,
) async {
final scores = <PangeaToken, int>{};
for (final token in tokens) {
@ -224,7 +236,7 @@ class PracticeSelectionRepo {
.pangeaController
.matrixState
.analyticsDataService
.getConstructUses(ids);
.getConstructUses(ids, language);
for (final token in tokens) {
final construct = constructs[idMap[token]];

View file

@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
@ -181,6 +182,20 @@ class PracticeController with ChangeNotifier {
// we don't take off points for incorrect emoji matches
if (_activity is! EmojiPracticeActivityModel || isCorrect) {
final l2 =
MatrixState.pangeaController.userController.userL2?.langCodeShort;
if (l2 == null) {
ErrorHandler.logError(
e: "User L2 is null when trying to log construct use for token ${token.text.content} in practice activity",
data: {
"eventId": pangeaMessageEvent.eventId,
"token": token.text.content,
"activityType": _activity!.activityType.toString(),
},
);
return;
}
final constructUseType = PracticeRecordController.lastResponse(
_activity!.practiceTarget,
)!.useType(_activity!.activityType);
@ -202,7 +217,7 @@ class PracticeController with ChangeNotifier {
),
];
updateService.addAnalytics(targetId, constructs);
updateService.addAnalytics(targetId, constructs, l2);
}
if (isCorrect) {

View file

@ -220,6 +220,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
event.eventId,
"word-zoom-card-${token.text.uniqueKey}",
token,
pangeaMessageEvent.messageDisplayLangCode.split('-').first,
Matrix.of(context).analyticsDataService,
roomId: event.room.id,
eventId: event.eventId,

View file

@ -11,6 +11,7 @@ mixin TokenRenderingMixin {
String cacheKey,
String targetId,
PangeaToken token,
String language,
AnalyticsDataService analyticsService, {
String? roomId,
String? eventId,
@ -36,7 +37,11 @@ mixin TokenRenderingMixin {
),
];
await analyticsService.updateService.addAnalytics(targetId, constructs);
await analyticsService.updateService.addAnalytics(
targetId,
constructs,
language,
);
TokensUtil.clearNewTokenCache();
}
}

View file

@ -213,11 +213,12 @@ class UserController {
// Do not await. This function pulls level from analytics,
// so it waits for analytics to finish initializing. Analytics waits for user controller to
// finish initializing, so this would cause a deadlock.
if (publicProfile!.analytics.isEmpty) {
final l2 = userL2;
if (publicProfile!.analytics.isEmpty && l2 != null) {
final analyticsService =
MatrixState.pangeaController.matrixState.analyticsDataService;
final data = await analyticsService.derivedData;
final data = await analyticsService.derivedData(l2.langCodeShort);
updateAnalyticsProfile(level: data.level);
}
}