diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 830eaafec..7187309c3 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2227,8 +2227,9 @@ class ChatController extends State ), ]; - _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 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 Future _showAnalyticsFeedback( List 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( diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart index 0f9b7d634..05e73e717 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart @@ -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 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, ); diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart index c03693cc1..f0a08666a 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_vocab_widget.dart @@ -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(() {}); diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 9e9706ae4..32b5d5dc0 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -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 _initMergeTable() async { + Future _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 reinitialize() async { Logs().i("Reinitializing analytics database."); initCompleter = Completer(); - await _clearDatabase(); + _clear(); await _initDatabase(_analyticsClientGetter.client); } - Future _clearDatabase() async { - await _analyticsClient?.database.clear(); + void _clear() { _invalidateCaches(); _mergeTable.clear(); } @@ -190,8 +215,7 @@ class AnalyticsDataService { Future _closeDatabase() async { await _analyticsClient?.database.delete(); _analyticsClient = null; - _invalidateCaches(); - _mergeTable.clear(); + _clear(); } Future _ensureInitialized() => @@ -217,23 +241,24 @@ class AnalyticsDataService { DerivedAnalyticsDataModel? get cachedDerivedData => _cachedDerivedStats; - Future get derivedData async { + Future derivedData(String language) async { await _ensureInitialized(); if (_cachedDerivedStats == null || _derivedCacheVersion != _cacheVersion) { _cachedDerivedStats = await _analyticsClientGetter.database - .getDerivedStats(); + .getDerivedStats(language); _derivedCacheVersion = _cacheVersion; } return _cachedDerivedStats!; } - Future getLastUpdatedAnalytics() async { - return _analyticsClientGetter.database.getLastEventTimestamp(); + Future getLastUpdatedAnalytics(String language) async { + return _analyticsClientGetter.database.getLastEventTimestamp(language); } - Future> getUses({ + Future> 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> getLocalUses() async { + Future> getLocalUses(String language) async { await _ensureInitialized(); - return _analyticsClientGetter.database.getLocalUses(); + return _analyticsClientGetter.database.getLocalUses(language); } - Future getLocalConstructCount() async { + Future getLocalConstructCount(String language) async { await _ensureInitialized(); - return _analyticsClientGetter.database.getLocalConstructCount(); + return _analyticsClientGetter.database.getLocalConstructCount(language); } - Future getConstructUse(ConstructIdentifier id) async { + Future 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> getConstructUses( List ids, + String language, ) async { await _ensureInitialized(); final Map> 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> 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 getNewConstructCount( List 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 updateXPOffset(int offset) async { + Future updateXPOffset(int offset, String language) async { _invalidateCaches(); - await _analyticsClientGetter.database.updateXPOffset(offset); + await _analyticsClientGetter.database.updateXPOffset(offset, language); } Future> updateLocalAnalytics( AnalyticsUpdate update, + String language, ) async { final events = []; 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 updateServerAnalytics( List 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 updateBlockedConstructs(ConstructIdentifier constructId) async { + Future 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 clearLocalAnalytics() async { + Future clearLocalAnalytics(String language) async { _invalidateCaches(); await _ensureInitialized(); - await _analyticsClientGetter.database.clearLocalConstructData(); + await _analyticsClientGetter.database.clearLocalConstructData(language); } } diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index b24b8a1fb..f972ed473 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -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 getUserID() => _lastEventTimestampBox.get('user_id'); - Future getLastUpdated() async { - final entry = await _lastEventTimestampBox.get('last_updated'); + Future getLastUpdated(String language) async { + final entry = await _lastEventTimestampBox.get( + _langKey('last_updated', language), + ); if (entry == null) return null; return DateTime.tryParse(entry); } - Future getLastEventTimestamp() async { + Future 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 getDerivedStats() async { - final raw = await _derivedStatsBox.get('derived_stats'); + Future getCurrentLanguage() async { + return _lastEventTimestampBox.get('current_language'); + } + + Future getDerivedStats(String language) async { + final raw = await _derivedStatsBox.get(_langKey('derived_stats', language)); return raw == null ? DerivedAnalyticsDataModel() : DerivedAnalyticsDataModel.fromJson(Map.from(raw)); } - Future> getUses({ + Future> 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> getLocalUses() async { + Future> getLocalUses(String language) async { final List 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 getLocalConstructCount() async { - final keys = await _localConstructsBox.getAllKeys(); + Future getLocalConstructCount(String language) async { + final keys = (await _localConstructsBox.getAllKeys()).where( + (key) => _isLanguageKey(key, language), + ); return keys.length; } - Future> getVocabConstructKeys() async { - final serverKeys = await _aggregatedServerVocabConstructsBox.getAllKeys(); - final localKeys = await _aggregatedLocalVocabConstructsBox.getAllKeys(); - return [...serverKeys, ...localKeys]; - } - - Future> getMorphConstructKeys() async { - final serverKeys = await _aggregatedServerMorphConstructsBox.getAllKeys(); - final localKeys = await _aggregatedLocalMorphConstructsBox.getAllKeys(); - return [...serverKeys, ...localKeys]; - } - - Future getConstructUse(List ids) async { + Future getConstructUse( + List 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.from(serverRaw)); } - final localRaw = await localBox.get(key); + final localRaw = await localBox.get(_langKey(key, language)); if (localRaw != null) { local = ConstructUses.fromJson(Map.from(localRaw)); } @@ -333,28 +349,46 @@ class AnalyticsDatabase with DatabaseFileStorage { Future> getConstructUses( Map> ids, + String language, ) async { final Map 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 clearLocalConstructData() async { + Future 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> _groupUses(List uses) { + Map> _groupUses( + List uses, + String language, + ) { final Map> 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> getAggregatedConstructs( ConstructTypeEnum type, + String language, ) async { Map 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 updateLastUpdated(DateTime timestamp) { + Future updateCurrentLanguage(String language) { + return _transaction(() async { + await _lastEventTimestampBox.put('current_language', language); + }); + } + + Future _updateLastUpdated(DateTime timestamp, String language) async { return _transaction(() async { await _lastEventTimestampBox.put( - 'last_updated', + _langKey('last_updated', language), timestamp.toIso8601String(), ); }); } - Future updateXPOffset(int offset) { + Future 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 updateTotalXP(int totalXP) { + Future 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 updateDerivedStats(DerivedAnalyticsDataModel newStats) => - _derivedStatsBox.put('derived_stats', newStats.toJson()); + Future updateDerivedStats( + DerivedAnalyticsDataModel newStats, + String language, + ) => _derivedStatsBox.put( + _langKey('derived_stats', language), + newStats.toJson(), + ); Future updateServerAnalytics( List 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 aggregatedVocabUses = []; final List 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 updateLocalAnalytics(List uses) async { + Future updateLocalAnalytics( + List 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"); diff --git a/lib/pangea/analytics_data/analytics_sync_controller.dart b/lib/pangea/analytics_data/analytics_sync_controller.dart index 44ffb1ee7..69ade0fb3 100644 --- a/lib/pangea/analytics_data/analytics_sync_controller.dart +++ b/lib/pangea/analytics_data/analytics_sync_controller.dart @@ -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 _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 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 _onConstructEvents( List 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 _onBlockedConstructEvents(List events) async { + Future _onBlockedConstructEvents( + List 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 bulkUpdate() async { + Future 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); } diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index e100aeffc..044e8581c 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -87,9 +87,10 @@ class AnalyticsUpdateDispatcher { Future sendBlockedConstructsUpdate( Set 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 sendServerAnalyticsUpdate( List events, + String language, ) async { - await dataService.updateServerAnalytics(events); + await dataService.updateServerAnalytics(events, language); sendEmptyAnalyticsUpdate(); } - Future sendLocalAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { - final events = await dataService.updateLocalAnalytics(analyticsUpdate); + Future sendLocalAnalyticsUpdate( + AnalyticsUpdate analyticsUpdate, + String language, + ) async { + final events = await dataService.updateLocalAnalytics( + analyticsUpdate, + language, + ); for (final event in events) { _dispatch(event); } diff --git a/lib/pangea/analytics_data/analytics_update_service.dart b/lib/pangea/analytics_data/analytics_update_service.dart index d490227ec..caab065c0 100644 --- a/lib/pangea/analytics_data/analytics_update_service.dart +++ b/lib/pangea/analytics_data/analytics_update_service.dart @@ -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 addAnalytics( String? targetID, - List newConstructs, { + List 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 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(); 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 _updateAnalytics({LanguageModel? l2Override}) async { - final localConstructs = await dataService.getLocalUses(); + Future _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; } diff --git a/lib/pangea/analytics_data/analytics_updater_mixin.dart b/lib/pangea/analytics_data/analytics_updater_mixin.dart index 11b201e47..c2281097d 100644 --- a/lib/pangea/analytics_data/analytics_updater_mixin.dart +++ b/lib/pangea/analytics_data/analytics_updater_mixin.dart @@ -33,9 +33,12 @@ mixin AnalyticsUpdater on State { Future addAnalytics( List 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) { diff --git a/lib/pangea/analytics_data/level_up_analytics_service.dart b/lib/pangea/analytics_data/level_up_analytics_service.dart index 9210bf802..e5d5a0eb9 100644 --- a/lib/pangea/analytics_data/level_up_analytics_service.dart +++ b/lib/pangea/analytics_data/level_up_analytics_service.dart @@ -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, diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index 5cebe36c8..3ac13dfab 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -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 { super.dispose(); } + LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; + Future _setAnalyticsData() async { - final 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 = [_setMorphs(), _setVocab(l2.langCodeShort)]; await Future.wait(future); } @@ -104,11 +116,12 @@ class ConstructAnalyticsViewState extends State { } } - Future _setVocab() async { + Future _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 { 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 { 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 { await TokenFeedbackUtil.showTokenFeedbackDialog( context, requestData: requestData, - langCode: MatrixState.pangeaController.userController.userL2Code!, + langCode: l2.langCode, onUpdated: () => reloadNotifier.value++, ); } diff --git a/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart b/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart index 84c8bc369..4670d6c22 100644 --- a/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart +++ b/lib/pangea/analytics_details_popup/construct_xp_progress_bar.dart @@ -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); diff --git a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart index e2ccde7e7..f25b06d3d 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_list_view.dart @@ -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 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, diff --git a/lib/pangea/analytics_details_popup/morph_details_view.dart b/lib/pangea/analytics_details_popup/morph_details_view.dart index bc2ca77b0..3a1dbb61c 100644 --- a/lib/pangea/analytics_details_popup/morph_details_view.dart +++ b/lib/pangea/analytics_details_popup/morph_details_view.dart @@ -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; diff --git a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart index 1dddb1758..51a083510 100644 --- a/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_analytics_details_view.dart @@ -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; diff --git a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart index 5337a4abf..829484396 100644 --- a/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart +++ b/lib/pangea/analytics_downloads/analytics_dowload_dialog.dart @@ -129,9 +129,15 @@ class AnalyticsDownloadDialogState extends State { } Future> _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 { } Future> _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 { 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]); diff --git a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart index 70d5eec55..b57cbcfbb 100644 --- a/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart +++ b/lib/pangea/analytics_misc/lemma_emoji_setter_mixin.dart @@ -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); } } diff --git a/lib/pangea/analytics_misc/level_up/level_up_banner.dart b/lib/pangea/analytics_misc/level_up/level_up_banner.dart index f026ad550..7cc82dbb9 100644 --- a/lib/pangea/analytics_misc/level_up/level_up_banner.dart +++ b/lib/pangea/analytics_misc/level_up/level_up_banner.dart @@ -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 ); _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); } } diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 618c3efb4..791595bfb 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -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 { SessionLoader({required this.type}); @override - Future fetch() => - AnalyticsPracticeSessionRepo.get(type); + Future 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 AnalyticsDataService get _analyticsService => Matrix.of(context).analyticsDataService; + LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2; + List filteredChoices( MultipleChoicePracticeActivityModel activity, ) { @@ -248,7 +255,7 @@ class AnalyticsPracticeState extends State } 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 await _analyticsService.updateService.addAnalytics( null, bonus, + _l2!.langCodeShort, forceUpdate: true, ); AnalyticsPractice.bypassExitConfirmation = true; @@ -534,7 +542,9 @@ class AnalyticsPracticeState extends State 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 await _analyticsService.updateService.addAnalytics( choiceTargetId(choiceContent), [use], + _l2!.langCodeShort, ); if (!isCorrect) return; @@ -663,7 +674,7 @@ class AnalyticsPracticeState extends State } 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 } Future get derivedAnalyticsData => - _analyticsService.derivedData; + _analyticsService.derivedData(_l2!.langCodeShort); /// Returns congratulations message based on performance String getCompletionMessage(BuildContext context) { diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 41dc98542..d1a2ae7a8 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -28,6 +28,7 @@ class InsufficientDataException implements Exception {} class AnalyticsPracticeSessionRepo { static Future 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> _fetchVocab() async { + static Future> _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> - _fetchAudio() async { + static Future> _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> _fetchMorphs() async { + static Future> _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> _fetchErrors() async { + static Future> _fetchErrors( + String language, + ) async { final allRecentUses = await MatrixState .pangeaController .matrixState .analyticsDataService .getUses( + language, count: 300, filterCapped: false, types: [ diff --git a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart index 3f2f7e489..963c74f71 100644 --- a/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_audio_activity_generator.dart @@ -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) diff --git a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart index 14757ddf0..c042929ec 100644 --- a/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart +++ b/lib/pangea/analytics_practice/vocab_meaning_activity_generator.dart @@ -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)) { diff --git a/lib/pangea/analytics_summary/learning_progress_indicators.dart b/lib/pangea/analytics_summary/learning_progress_indicators.dart index c3e91e20e..4414d57fa 100644 --- a/lib/pangea/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/analytics_summary/learning_progress_indicators.dart @@ -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; diff --git a/lib/pangea/analytics_summary/level_analytics_details_content.dart b/lib/pangea/analytics_summary/level_analytics_details_content.dart index 69ca9389b..b4afab4cc 100644 --- a/lib/pangea/analytics_summary/level_analytics_details_content.dart +++ b/lib/pangea/analytics_summary/level_analytics_details_content.dart @@ -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>( + future: language != null + ? analyticsService.getUses(language, count: 100) + : Future.value([]), builder: (context, snapshot) { if (!snapshot.hasData) { return const Center( diff --git a/lib/pangea/practice_activities/lemma_activity_generator.dart b/lib/pangea/practice_activities/lemma_activity_generator.dart index 291462ea5..74b559c95 100644 --- a/lib/pangea/practice_activities/lemma_activity_generator.dart +++ b/lib/pangea/practice_activities/lemma_activity_generator.dart @@ -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> 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 constructIds = constructs.keys.toList(); // Offload computation to an isolate diff --git a/lib/pangea/practice_activities/practice_selection_repo.dart b/lib/pangea/practice_activities/practice_selection_repo.dart index c677cfe8b..22058bb37 100644 --- a/lib/pangea/practice_activities/practice_selection_repo.dart +++ b/lib/pangea/practice_activities/practice_selection_repo.dart @@ -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>> _fillActivityQueue( List tokens, + String language, ) async { final queue = >{}; 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> _buildActivity( ActivityTypeEnum activityType, List tokens, + String language, ) async { if (activityType == ActivityTypeEnum.morphId) { - return _buildMorphActivity(tokens); + return _buildMorphActivity(tokens, language); } List practiceTokens = List.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> _buildMorphActivity( List tokens, + String language, ) async { final List practiceTokens = List.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> _fetchPriorityScores( List tokens, ActivityTypeEnum activityType, + String language, ) async { final scores = {}; 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]]; diff --git a/lib/pangea/toolbar/message_practice/practice_controller.dart b/lib/pangea/toolbar/message_practice/practice_controller.dart index 03d3af822..21270bb80 100644 --- a/lib/pangea/toolbar/message_practice/practice_controller.dart +++ b/lib/pangea/toolbar/message_practice/practice_controller.dart @@ -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) { diff --git a/lib/pangea/toolbar/message_selection_overlay.dart b/lib/pangea/toolbar/message_selection_overlay.dart index 7e08112fd..d182a8c6b 100644 --- a/lib/pangea/toolbar/message_selection_overlay.dart +++ b/lib/pangea/toolbar/message_selection_overlay.dart @@ -220,6 +220,7 @@ class MessageOverlayController extends State event.eventId, "word-zoom-card-${token.text.uniqueKey}", token, + pangeaMessageEvent.messageDisplayLangCode.split('-').first, Matrix.of(context).analyticsDataService, roomId: event.room.id, eventId: event.eventId, diff --git a/lib/pangea/toolbar/token_rendering_mixin.dart b/lib/pangea/toolbar/token_rendering_mixin.dart index 4d89aab61..44f69d96b 100644 --- a/lib/pangea/toolbar/token_rendering_mixin.dart +++ b/lib/pangea/toolbar/token_rendering_mixin.dart @@ -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(); } } diff --git a/lib/pangea/user/user_controller.dart b/lib/pangea/user/user_controller.dart index 3b58d1fb9..09669beaf 100644 --- a/lib/pangea/user/user_controller.dart +++ b/lib/pangea/user/user_controller.dart @@ -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); } }