diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index aa2851472..224c87f48 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -130,29 +130,24 @@ class AnalyticsDataService { await _analyticsClientGetter.database.updateUserID(client.userID!); } - final resp = await client.getUserProfile(client.userID!); - final analyticsProfile = AnalyticsProfileModel.fromJson( - resp.additionalProperties, - ); - _syncController?.dispose(); _syncController = AnalyticsSyncController( client: client, dataService: this, ); + await _syncController!.bulkUpdate(); - final vocab = await getAggregatedConstructs(ConstructTypeEnum.vocab); - final morphs = await getAggregatedConstructs(ConstructTypeEnum.morph); - final constructs = [...vocab.values, ...morphs.values]; - final totalXP = constructs.fold(0, (total, c) => total + c.points); - await _analyticsClientGetter.database.updateDerivedStats( - DerivedAnalyticsDataModel( - totalXP: totalXP, - offset: analyticsProfile.xpOffset ?? 0, - ), + 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); + } + _syncController!.start(); await _initMergeTable(); @@ -161,7 +156,7 @@ class AnalyticsDataService { } finally { Logs().i("Analytics database initialized."); initCompleter.complete(); - updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([])); + updateDispatcher.sendEmptyAnalyticsUpdate(); updateDispatcher.sendActivityAnalyticsUpdate(null); } } @@ -413,7 +408,7 @@ class AnalyticsDataService { final newConstructs = await getConstructUses(updateIds); int points = 0; - if (update.blockedConstruct == null || updateIds.isNotEmpty) { + if (updateIds.isNotEmpty) { for (final id in updateIds) { final prevPoints = prevConstructs[id]?.points ?? 0; final newPoints = newConstructs[id]?.points ?? 0; @@ -422,7 +417,7 @@ class AnalyticsDataService { events.add(XPGainedEvent(points, update.targetID)); } - final newData = prevData.copyWith(totalXP: prevData.totalXP + points); + final newData = prevData.addXP(points); await _analyticsClientGetter.database.updateDerivedStats(newData); // Update public profile each time that new analytics are added. @@ -474,10 +469,6 @@ class AnalyticsDataService { } } - if (update.blockedConstruct != null) { - events.add(ConstructBlockedEvent(update.blockedConstruct!)); - } - if (newUnusedConstructs.isNotEmpty) { events.add(NewConstructsEvent(newUnusedConstructs)); } @@ -494,6 +485,12 @@ class AnalyticsDataService { _mergeTable.addConstructsByUses(event.content.uses, blocked); } await _analyticsClientGetter.database.updateServerAnalytics(events); + final vocab = await getAggregatedConstructs(ConstructTypeEnum.vocab); + final morphs = await getAggregatedConstructs(ConstructTypeEnum.morph); + final constructs = [...vocab.values, ...morphs.values]; + final totalXP = constructs.fold(0, (total, c) => total + c.points); + + await _analyticsClientGetter.database.updateTotalXP(totalXP); } Future updateBlockedConstructs(ConstructIdentifier constructId) async { @@ -512,14 +509,8 @@ class AnalyticsDataService { level: newLevel, ); - await _analyticsClientGetter.database.updateDerivedStats( - DerivedAnalyticsDataModel(totalXP: newXP), - ); - + await _analyticsClientGetter.database.updateTotalXP(newXP); _invalidateCaches(); - updateDispatcher.sendConstructAnalyticsUpdate( - AnalyticsUpdate([], blockedConstruct: constructId), - ); } Future clearLocalAnalytics() async { diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index 91515bee2..b24b8a1fb 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -468,7 +468,15 @@ class AnalyticsDatabase with DatabaseFileStorage { Future updateXPOffset(int offset) { return _transaction(() async { final stats = await getDerivedStats(); - final updatedStats = stats.copyWith(offset: offset); + final updatedStats = stats.copyWithOffset(offset); + await _derivedStatsBox.put('derived_stats', updatedStats.toJson()); + }); + } + + Future updateTotalXP(int totalXP) { + return _transaction(() async { + final stats = await getDerivedStats(); + final updatedStats = stats.copyWithTotalXP(totalXP); await _derivedStatsBox.put('derived_stats', updatedStats.toJson()); }); } diff --git a/lib/pangea/analytics_data/analytics_sync_controller.dart b/lib/pangea/analytics_data/analytics_sync_controller.dart index 6d51507a6..2bd5e70b1 100644 --- a/lib/pangea/analytics_data/analytics_sync_controller.dart +++ b/lib/pangea/analytics_data/analytics_sync_controller.dart @@ -3,13 +3,36 @@ import 'dart:async'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; -import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; +import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart'; +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/lemmas/user_set_lemma_info.dart'; import 'package:fluffychat/widgets/matrix.dart'; +enum _AnalyticsUpdateEvent { + constructAnalytics, + activityAnalytics, + lemmaInfo, + blockedConstruct; + + String get eventType { + switch (this) { + case _AnalyticsUpdateEvent.constructAnalytics: + return PangeaEventTypes.construct; + case _AnalyticsUpdateEvent.activityAnalytics: + return PangeaEventTypes.activityRoomIds; + case _AnalyticsUpdateEvent.lemmaInfo: + return PangeaEventTypes.userSetLemmaInfo; + case _AnalyticsUpdateEvent.blockedConstruct: + return PangeaEventTypes.analyticsSettings; + } + } +} + class AnalyticsSyncController { final Client client; final AnalyticsDataService dataService; @@ -31,15 +54,43 @@ class AnalyticsSyncController { final analyticsRoom = _getAnalyticsRoom(); if (analyticsRoom == null) return; - final events = update.rooms?.join?[analyticsRoom.id]?.timeline?.events - ?.where( - (e) => - e.type == PangeaEventTypes.construct && - e.senderId == client.userID, - ); + final roomUpdates = update.rooms?.join?[analyticsRoom.id]?.timeline?.events; + if (roomUpdates == null) return; - if (events == null || events.isEmpty) return; + for (final type in _AnalyticsUpdateEvent.values) { + await _dispatchSyncEvents(type, roomUpdates, analyticsRoom); + } + } + Future _dispatchSyncEvents( + _AnalyticsUpdateEvent type, + List events, + Room analyticsRoom, + ) async { + final updates = events + .where((e) => e.type == type.eventType && e.senderId == client.userID) + .toList(); + + switch (type) { + case _AnalyticsUpdateEvent.constructAnalytics: + await _onConstructEvents(updates, analyticsRoom); + break; + case _AnalyticsUpdateEvent.activityAnalytics: + _onActivityEvents(updates); + break; + case _AnalyticsUpdateEvent.lemmaInfo: + _onLemmaInfoEvents(updates); + break; + case _AnalyticsUpdateEvent.blockedConstruct: + await _onBlockedConstructEvents(updates); + break; + } + } + + Future _onConstructEvents( + List events, + Room analyticsRoom, + ) async { final constructEvents = events .map( (e) => ConstructAnalyticsEvent( @@ -50,16 +101,65 @@ class AnalyticsSyncController { .toList(); if (constructEvents.isEmpty) return; - await dataService.updateServerAnalytics(constructEvents); - - // Server updates do not usually need to update the UI, since usually they are only - // transfering local data to the server. However, if a user if using multiple devices, - // we do need to update the UI when new data comes from the server. - dataService.updateDispatcher.sendConstructAnalyticsUpdate( - AnalyticsUpdate([]), + await dataService.updateDispatcher.sendServerAnalyticsUpdate( + constructEvents, ); } + void _onActivityEvents(List events) { + for (final event in events) { + if (event.content[ModelKey.roomIds] is! List) continue; + final roomIds = List.from( + event.content[ModelKey.roomIds]! as List, + ); + final prevContent = + event.unsigned?['prev_content'] as Map?; + final prevRoomIds = + prevContent != null && prevContent[ModelKey.roomIds] is List + ? List.from(prevContent[ModelKey.roomIds] as List) + : []; + final newRoomIds = roomIds + .where((id) => !prevRoomIds.contains(id)) + .toList(); + + if (newRoomIds.isNotEmpty) { + dataService.updateDispatcher.sendActivityAnalyticsUpdate(null); + } + } + } + + void _onLemmaInfoEvents(List events) { + for (final event in events) { + if (event.stateKey == null) continue; + final cID = ConstructIdentifier.fromString(event.stateKey!); + if (cID == null) continue; + + final update = UserSetLemmaInfo.fromJson(event.content); + dataService.updateDispatcher.sendLemmaInfoUpdate(cID, update); + } + } + + Future _onBlockedConstructEvents(List events) async { + for (final event in events) { + final current = AnalyticsSettingsModel.fromJson(event.content); + final prevContent = + event.unsigned?['prev_content'] as Map?; + final prev = prevContent != null + ? AnalyticsSettingsModel.fromJson(prevContent) + : null; + + final newBlocked = current.blockedConstructs; + final prevBlocked = prev?.blockedConstructs ?? {}; + + final newlyBlocked = newBlocked.where((c) => !prevBlocked.contains(c)); + for (final constructId in newlyBlocked) { + await dataService.updateDispatcher.sendBlockedConstructUpdate( + constructId, + ); + } + } + } + Future waitForSync(String analyticsRoomId) async { await client.onSync.stream.firstWhere((update) { final roomUpdate = update.rooms?.join?[analyticsRoomId]; diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index a89586944..c3e7a9e83 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; +import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; @@ -16,10 +17,9 @@ class LevelUpdate { class AnalyticsUpdate { final List addedConstructs; - final ConstructIdentifier? blockedConstruct; final String? targetID; - AnalyticsUpdate(this.addedConstructs, {this.blockedConstruct, this.targetID}); + AnalyticsUpdate(this.addedConstructs, {this.targetID}); } class ConstructLevelUpdate { @@ -85,9 +85,26 @@ class AnalyticsUpdateDispatcher { UserSetLemmaInfo lemmaInfo, ) => _lemmaInfoUpdateStream.add(MapEntry(constructId, lemmaInfo)); - Future sendConstructAnalyticsUpdate( - AnalyticsUpdate analyticsUpdate, + Future sendBlockedConstructUpdate( + ConstructIdentifier blockedConstruct, ) async { + await dataService.updateBlockedConstructs(blockedConstruct); + final update = AnalyticsStreamUpdate(blockedConstruct: blockedConstruct); + constructUpdateStream.add(update); + } + + void sendEmptyAnalyticsUpdate() { + constructUpdateStream.add(AnalyticsStreamUpdate()); + } + + Future sendServerAnalyticsUpdate( + List events, + ) async { + await dataService.updateServerAnalytics(events); + sendEmptyAnalyticsUpdate(); + } + + Future sendLocalAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { final events = await dataService.updateLocalAnalytics(analyticsUpdate); for (final event in events) { _dispatch(event); @@ -105,9 +122,6 @@ class AnalyticsUpdateDispatcher { case final XPGainedEvent e: _onXPGained(e.points, e.targetID); break; - case final ConstructBlockedEvent e: - _onBlockedConstruct(e.blockedConstruct); - break; case final ConstructLevelUpEvent e: _onConstructLevelUp(e.constructId, e.level, e.targetID); break; @@ -155,11 +169,6 @@ class AnalyticsUpdateDispatcher { ); } - void _onBlockedConstruct(ConstructIdentifier constructId) { - final update = AnalyticsStreamUpdate(blockedConstruct: constructId); - constructUpdateStream.add(update); - } - void _onNewConstruct(Set constructIds) { if (constructIds.isEmpty) return; newConstructsStream.add(constructIds); diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index a202baa06..e35767943 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -27,11 +27,6 @@ class XPGainedEvent extends AnalyticsUpdateEvent { XPGainedEvent(this.points, this.targetID); } -class ConstructBlockedEvent extends AnalyticsUpdateEvent { - final ConstructIdentifier blockedConstruct; - ConstructBlockedEvent(this.blockedConstruct); -} - class NewConstructsEvent extends AnalyticsUpdateEvent { final Set newConstructs; NewConstructsEvent(this.newConstructs); diff --git a/lib/pangea/analytics_data/analytics_update_service.dart b/lib/pangea/analytics_data/analytics_update_service.dart index f133d199b..156b035c8 100644 --- a/lib/pangea/analytics_data/analytics_update_service.dart +++ b/lib/pangea/analytics_data/analytics_update_service.dart @@ -62,7 +62,7 @@ class AnalyticsUpdateService { List newConstructs, { bool forceUpdate = false, }) async { - await dataService.updateDispatcher.sendConstructAnalyticsUpdate( + await dataService.updateDispatcher.sendLocalAnalyticsUpdate( AnalyticsUpdate(newConstructs, targetID: targetID), ); @@ -127,9 +127,6 @@ class AnalyticsUpdateService { if (analyticsRoom == null) return; await analyticsRoom.addActivityRoomId(roomId); - if (lang.langCodeShort == _l2?.langCodeShort) { - dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId); - } } Future blockConstruct(ConstructIdentifier constructId) async { @@ -143,7 +140,6 @@ class AnalyticsUpdateService { ); await analyticsRoom.setAnalyticsSettings(updated); - await dataService.updateBlockedConstructs(constructId); } Future setLemmaInfo( @@ -160,7 +156,6 @@ class AnalyticsUpdateService { meaning: meaning, ); if (userLemmaInfo == updated) return; - dataService.updateDispatcher.sendLemmaInfoUpdate(constructId, updated); try { await analyticsRoom.setUserSetLemmaInfo(constructId, updated); diff --git a/lib/pangea/analytics_data/derived_analytics_data_model.dart b/lib/pangea/analytics_data/derived_analytics_data_model.dart index 0d97a2736..2b5416778 100644 --- a/lib/pangea/analytics_data/derived_analytics_data_model.dart +++ b/lib/pangea/analytics_data/derived_analytics_data_model.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; class DerivedAnalyticsDataModel { @@ -12,7 +11,7 @@ class DerivedAnalyticsDataModel { int get totalXP => _totalXP + offset; - int get level => calculateLevelWithXp(_totalXP); + int get level => calculateLevelWithXp(totalXP); // the minimum XP required for a given level int get _minXPForLevel => calculateXpWithLevel(level); @@ -23,7 +22,7 @@ class DerivedAnalyticsDataModel { // the progress within the current level as a percentage (0.0 to 1.0) double get levelProgress { final progress = - (_totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel); + (totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel); return progress >= 0 ? progress : 0; } @@ -60,35 +59,29 @@ class DerivedAnalyticsDataModel { } } - DerivedAnalyticsDataModel update(List uses) { - int xp = _totalXP; - - for (final u in uses) { - xp += u.xp; - } - - return copyWith(totalXP: xp); + DerivedAnalyticsDataModel copyWithOffset(int offset) { + return DerivedAnalyticsDataModel(totalXP: _totalXP, offset: offset); } - DerivedAnalyticsDataModel merge(DerivedAnalyticsDataModel other) { + DerivedAnalyticsDataModel copyWithTotalXP(int totalXP) { + return DerivedAnalyticsDataModel(totalXP: totalXP, offset: offset); + } + + DerivedAnalyticsDataModel addXP(int xpToAdd) { return DerivedAnalyticsDataModel( - totalXP: _totalXP + other.totalXP, + totalXP: _totalXP + xpToAdd, offset: offset, ); } - DerivedAnalyticsDataModel copyWith({int? totalXP, int? offset}) { + factory DerivedAnalyticsDataModel.fromJson(Map map) { return DerivedAnalyticsDataModel( - totalXP: totalXP ?? this.totalXP, - offset: offset ?? this.offset, + totalXP: map['total_xp'] ?? 0, + offset: map['offset'] ?? 0, ); } - factory DerivedAnalyticsDataModel.fromJson(Map map) { - return DerivedAnalyticsDataModel(totalXP: map['total_xp'] ?? 0); - } - Map toJson() { - return {'total_xp': _totalXP}; + return {'total_xp': _totalXP, 'offset': offset}; } } diff --git a/lib/pangea/user/analytics_profile_model.dart b/lib/pangea/user/analytics_profile_model.dart index c7ed6c35a..a4abb9560 100644 --- a/lib/pangea/user/analytics_profile_model.dart +++ b/lib/pangea/user/analytics_profile_model.dart @@ -136,6 +136,9 @@ class AnalyticsProfileModel { int? get level => languageAnalytics?[targetLanguage]?.level; int? get xpOffset => languageAnalytics?[targetLanguage]?.xpOffset; + + int? xpOffsetByLanguage(LanguageModel language) => + languageAnalytics?[language]?.xpOffset; } class LanguageAnalyticsProfileEntry { diff --git a/lib/pangea/user/user_controller.dart b/lib/pangea/user/user_controller.dart index da0196b64..89483ff5c 100644 --- a/lib/pangea/user/user_controller.dart +++ b/lib/pangea/user/user_controller.dart @@ -37,8 +37,8 @@ class LanguageUpdate { class UserController { final StreamController languageStream = StreamController.broadcast(); - final StreamController settingsUpdateStream = - StreamController.broadcast(); + final StreamController settingsUpdateStream = + StreamController.broadcast(); /// Cached version of the user profile, so it doesn't have /// to be read in from client's account data each time it is accessed. @@ -52,10 +52,40 @@ class UserController { matrix.Client get client => MatrixState.pangeaController.matrixState.client; void _onProfileUpdate(matrix.SyncUpdate sync) { + final prevTargetLang = userL2; + final prevBaseLang = userL1; + final profileData = client.accountData[ModelKey.userProfile]?.content; final Profile? fromAccountData = Profile.fromAccountData(profileData); - if (fromAccountData != null) { + if (fromAccountData != null && fromAccountData != _cachedProfile) { _cachedProfile = fromAccountData; + + if ((prevTargetLang != userL2) || (prevBaseLang != userL1)) { + if (userL1 == null || userL2 == null) { + // if either language is null, then we want to send a settings update instead of a language update + ErrorHandler.logError( + e: "One of the user languages is null. Sending settings update instead of language update.", + data: { + 'prevBaseLang': prevBaseLang?.langCode, + 'prevTargetLang': prevTargetLang?.langCode, + 'userL1': userL1?.langCode, + 'userL2': userL2?.langCode, + }, + ); + settingsUpdateStream.add(fromAccountData); + return; + } + languageStream.add( + LanguageUpdate( + baseLang: userL1!, + targetLang: userL2!, + prevBaseLang: prevBaseLang, + prevTargetLang: prevTargetLang, + ), + ); + } else { + settingsUpdateStream.add(fromAccountData); + } } } @@ -92,8 +122,6 @@ class UserController { waitForDataInSync = false, }) async { await initialize(); - final prevTargetLang = userL2; - final prevBaseLang = userL1; final prevHash = profile.hashCode; final Profile updatedProfile = update(profile); @@ -103,19 +131,6 @@ class UserController { } await updatedProfile.saveProfileData(waitForDataInSync: waitForDataInSync); - - if ((prevTargetLang != userL2) || (prevBaseLang != userL1)) { - languageStream.add( - LanguageUpdate( - baseLang: userL1!, - targetLang: userL2!, - prevBaseLang: prevBaseLang, - prevTargetLang: prevTargetLang, - ), - ); - } else { - settingsUpdateStream.add(updatedProfile); - } } /// A completer for the profile model of a user. diff --git a/pubspec.lock b/pubspec.lock index e7d3e9179..47cfd7155 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -538,6 +538,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + fcm_shared_isolate: + dependency: "direct overridden" + description: + path: "pangea_packages/fcm_shared_isolate" + relative: true + source: path + version: "0.2.0" ffi: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 188e91b74..fa51d0f34 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -170,4 +170,6 @@ flutter: # 1. Don't do it if you can avoid it or fix it upstream in a manageable time # 2. Always link an (upstream?) issue # 3. Explain how and when this can be removed (overrides must be temporarily) -dependency_overrides: \ No newline at end of file +dependency_overrides: + fcm_shared_isolate: + path: pangea_packages/fcm_shared_isolate \ No newline at end of file