feat: analytics database

This commit is contained in:
ggurdin 2025-12-23 14:35:41 -05:00 committed by GitHub
parent b363021504
commit d8caf8e481
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 3818 additions and 3408 deletions

View file

@ -30,10 +30,11 @@ import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_controller.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_feedback.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
@ -50,6 +51,7 @@ import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
@ -179,14 +181,14 @@ class ChatPageWithRoom extends StatefulWidget {
}
class ChatController extends State<ChatPageWithRoom>
with WidgetsBindingObserver {
with WidgetsBindingObserver, AnalyticsUpdater {
// #Pangea
final PangeaController pangeaController = MatrixState.pangeaController;
late Choreographer choreographer;
late GoRouter _router;
StreamSubscription? _levelSubscription;
StreamSubscription? _analyticsSubscription;
StreamSubscription? _constructsSubscription;
StreamSubscription? _botAudioSubscription;
final timelineUpdateNotifier = _TimelineUpdateNotifier();
late final ActivityChatController activityController;
@ -454,25 +456,20 @@ class ChatController extends State<ChatPageWithRoom>
}
// #Pangea
void _onLevelUp(dynamic update) {
if (update['level_up'] != null) {
LevelUpUtil.showLevelUpDialog(
update['upper_level'],
update['lower_level'],
context,
);
} else if (update['unlocked_constructs'] != null) {
ConstructNotificationUtil.addUnlockedConstruct(
List.from(update['unlocked_constructs']),
context,
);
}
void _onLevelUp(LevelUpdate update) {
LevelUpUtil.showLevelUpDialog(
update.newLevel,
update.prevLevel,
context,
);
}
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID != null) {
OverlayUtil.showPointsGained(update.targetID!, update.points, context);
}
void _onUnlockConstructs(Set<ConstructIdentifier> constructs) {
if (constructs.isEmpty) return;
ConstructNotificationUtil.addUnlockedConstruct(
List.from(constructs),
context,
);
}
Future<void> _botAudioListener(SyncUpdate update) async {
@ -517,18 +514,18 @@ class ChatController extends State<ChatPageWithRoom>
void _pangeaInit() {
choreographer = Choreographer(inputFocus);
_levelSubscription =
pangeaController.getAnalytics.stateStream.listen(_onLevelUp);
final updater = Matrix.of(context).analyticsDataService.updateDispatcher;
_analyticsSubscription = pangeaController
.getAnalytics.analyticsStream.stream
.listen(_onAnalyticsUpdate);
_levelSubscription = updater.levelUpdateStream.stream.listen(_onLevelUp);
_constructsSubscription =
updater.unlockedConstructsStream.stream.listen(_onUnlockConstructs);
_botAudioSubscription = room.client.onSync.stream.listen(_botAudioListener);
activityController = ActivityChatController(
userID: Matrix.of(context).client.userID!,
getAnalytics: room.getActivityAnalytics,
room: room,
);
Future.delayed(const Duration(seconds: 1), () async {
@ -785,8 +782,8 @@ class ChatController extends State<ChatPageWithRoom>
MatrixState.pAnyState.closeAllOverlays(force: true);
stopMediaStream.close();
_levelSubscription?.cancel();
_analyticsSubscription?.cancel();
_botAudioSubscription?.cancel();
_constructsSubscription?.cancel();
_router.routeInformationProvider.removeListener(_onRouteChanged);
scrollController.dispose();
inputFocus.dispose();
@ -2118,12 +2115,7 @@ class ChatController extends State<ChatPageWithRoom>
];
_showAnalyticsFeedback(constructs, eventId);
pangeaController.putAnalytics.addAnalytics(
constructs,
eventId: eventId,
targetId: eventId,
roomId: room.id,
);
addAnalytics(constructs, eventId);
}
}
@ -2169,12 +2161,10 @@ class ChatController extends State<ChatPageWithRoom>
if (constructs.isEmpty) return;
_showAnalyticsFeedback(constructs, eventId);
MatrixState.pangeaController.putAnalytics.addAnalytics(
constructs,
eventId: eventId,
targetId: eventId,
roomId: room.id,
);
Matrix.of(context).analyticsDataService.updateService.addAnalytics(
eventId,
constructs,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
@ -2241,17 +2231,17 @@ class ChatController extends State<ChatPageWithRoom>
);
}
void _showAnalyticsFeedback(
Future<void> _showAnalyticsFeedback(
List<OneConstructUse> constructs,
String eventId,
) {
final newGrammarConstructs =
pangeaController.getAnalytics.newConstructCount(
) async {
final analyticsService = Matrix.of(context).analyticsDataService;
final newGrammarConstructs = await analyticsService.getNewConstructCount(
constructs,
ConstructTypeEnum.morph,
);
final newVocabConstructs = pangeaController.getAnalytics.newConstructCount(
final newVocabConstructs = await analyticsService.getNewConstructCount(
constructs,
ConstructTypeEnum.vocab,
);

View file

@ -449,8 +449,6 @@ class HtmlMessage extends StatelessWidget {
selectModeNotifier: overlayController!.selectedMode,
onTap: () =>
overlayController!.onClickOverlayMessageToken(token),
constructEmojiNotifier: overlayController!
.selectModeController.constructEmojiNotifier,
textColor: textColor,
),
if (renderer.showCenterStyling &&
@ -972,8 +970,6 @@ class HtmlMessage extends StatelessWidget {
selectModeNotifier: overlayController!.selectedMode,
onTap: () {},
enabled: false,
constructEmojiNotifier: overlayController!
.selectModeController.constructEmojiNotifier,
textColor: textColor,
),
RichText(

View file

@ -8,11 +8,9 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.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/activity_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
@ -22,7 +20,6 @@ import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extensio
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../activity_summary/activity_summary_repo.dart';
class RoleException implements Exception {
@ -311,48 +308,6 @@ extension ActivityRoomExtension on Room {
}
}
Future<ActivitySummaryAnalyticsModel> getActivityAnalytics() async {
// wait for local storage box to init in getAnalytics initialization
if (!MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted) {
await MatrixState.pangeaController.getAnalytics.initCompleter.future;
}
final cached = ActivitySessionAnalyticsRepo.get(id);
final analytics = cached?.analytics ?? ActivitySummaryAnalyticsModel();
DateTime? timestamp = creationTimestamp;
if (cached != null) {
timestamp = cached.lastUseTimestamp;
}
final List<OneConstructUse> uses = [];
for (final use
in MatrixState.pangeaController.getAnalytics.constructListModel.uses) {
final useTimestamp = use.metadata.timeStamp;
if (timestamp != null &&
(useTimestamp == timestamp || useTimestamp.isBefore(timestamp))) {
break;
}
if (use.metadata.roomId != id) continue;
uses.add(use);
}
if (uses.isEmpty) {
return analytics;
}
analytics.addConstructs(client.userID!, uses);
await ActivitySessionAnalyticsRepo.set(
id,
uses.first.metadata.timeStamp,
analytics,
);
return analytics;
}
// UI-related helper functions
bool get showActivityChatUI {

View file

@ -2,19 +2,24 @@ import 'dart:async';
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_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';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityChatController {
final String userID;
final Future<ActivitySummaryAnalyticsModel> Function()? getAnalytics;
final Room room;
ActivityChatController({
required this.userID,
required this.getAnalytics,
required this.room,
}) {
init();
}
@ -30,14 +35,10 @@ class ActivityChatController {
final ValueNotifier<bool> hasRainedConfetti = ValueNotifier(false);
void init() {
if (getAnalytics != null) {
_updateUsedVocab();
_analyticsSubscription = MatrixState
.pangeaController.getAnalytics.analyticsStream.stream
.listen((_) {
_updateUsedVocab();
});
}
_updateUsedVocab();
_analyticsSubscription = MatrixState.pangeaController.matrixState
.analyticsDataService.updateDispatcher.constructUpdateStream.stream
.listen((_) => _updateUsedVocab());
}
void dispose() {
@ -76,10 +77,8 @@ class ActivityChatController {
}
Future<void> _updateUsedVocab() async {
if (getAnalytics == null) return;
try {
final analytics = await getAnalytics!.call();
final analytics = await getActivityAnalytics();
if (!_disposed) {
usedVocab.value = analytics.constructs[userID]
?.constructsOfType(ConstructTypeEnum.vocab)
@ -97,4 +96,36 @@ class ActivityChatController {
);
}
}
Future<ActivitySummaryAnalyticsModel> getActivityAnalytics() async {
final cached = ActivitySessionAnalyticsRepo.get(room.id);
final analytics = cached?.analytics ?? ActivitySummaryAnalyticsModel();
DateTime? timestamp = room.creationTimestamp;
if (cached != null) {
timestamp = cached.lastUseTimestamp;
}
List<OneConstructUse> uses = [];
final analyticsService =
MatrixState.pangeaController.matrixState.analyticsDataService;
uses = await analyticsService.getUses(
since: timestamp ?? DateTime.fromMillisecondsSinceEpoch(0),
roomId: room.id,
);
if (uses.isEmpty) {
return analytics;
}
analytics.addConstructs(userID, uses);
await ActivitySessionAnalyticsRepo.set(
room.id,
uses.first.metadata.timeStamp,
analytics,
);
return analytics;
}
}

View file

@ -36,7 +36,9 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
Future<void> _archiveToAnalytics(BuildContext context) async {
await controller.room.archiveActivity();
await MatrixState.pangeaController.putAnalytics
await Matrix.of(context)
.analyticsDataService
.updateService
.sendActivityAnalytics(controller.room.id);
}

View file

@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/tutorial_overlay_message.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityMenuButton extends StatefulWidget {
final ChatController controller;
@ -34,8 +35,11 @@ class _ActivityMenuButtonState extends State<ActivityMenuButton> {
void initState() {
super.initState();
_analyticsSubscription = widget
.controller.pangeaController.getAnalytics.analyticsStream.stream
_analyticsSubscription = Matrix.of(context)
.analyticsDataService
.updateDispatcher
.constructUpdateStream
.stream
.listen(_showStatsMenuDropdownInstructions);
_rolesSubscription = widget.controller.room.client.onRoomState.stream

View file

@ -0,0 +1,456 @@
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_database.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_database_builder.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_sync_controller.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart';
import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/user/analytics_profile_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class _AnalyticsClient {
final Client client;
final AnalyticsDatabase database;
_AnalyticsClient({
required this.client,
required this.database,
});
}
class AnalyticsStreamUpdate {
final int points;
final ConstructIdentifier? blockedConstruct;
final String? targetID;
AnalyticsStreamUpdate({
this.points = 0,
this.blockedConstruct,
this.targetID,
});
}
class AnalyticsDataService {
_AnalyticsClient? _analyticsClient;
late final AnalyticsUpdateDispatcher updateDispatcher;
late final AnalyticsUpdateService updateService;
late final LevelUpAnalyticsService levelUpService;
AnalyticsSyncController? _syncController;
final ConstructMergeTable _mergeTable = ConstructMergeTable();
Completer<void> _initCompleter = Completer<void>();
AnalyticsDataService(Client client) {
updateDispatcher = AnalyticsUpdateDispatcher(this);
updateService = AnalyticsUpdateService(this);
levelUpService = LevelUpAnalyticsService(
client: client,
ensureInitialized: () => _ensureInitialized(),
dataService: this,
);
_initDatabase(client);
}
static const int _morphUnlockXP = 30;
int _cacheVersion = 0;
int _derivedCacheVersion = -1;
DerivedAnalyticsDataModel? _cachedDerivedStats;
_AnalyticsClient get _analyticsClientGetter {
assert(_analyticsClient != null);
return _analyticsClient!;
}
bool get isInitializing => !_initCompleter.isCompleted;
Future<Room?> getAnalyticsRoom(LanguageModel l2) =>
_analyticsClientGetter.client.getMyAnalyticsRoom(l2);
void dispose() {
_syncController?.dispose();
updateDispatcher.dispose();
_closeDatabase();
}
void _invalidateCaches() {
_cacheVersion++;
_cachedDerivedStats = null;
}
Future<void> _initDatabase(Client client) async {
_invalidateCaches();
final database = await analyticsDatabaseBuilder(
"${client.clientName}_analytics",
);
_analyticsClient = _AnalyticsClient(client: client, database: database);
if (client.isLogged()) {
await _initAnalytics();
} else {
await client.onLoginStateChanged.stream.firstWhere(
(state) => state == LoginState.loggedIn,
);
await _initAnalytics();
}
}
Future<void> _initAnalytics() async {
try {
final client = _analyticsClientGetter.client;
if (client.prevBatch == null) {
await client.onSync.stream.first;
}
_invalidateCaches();
await _clearDatabase();
final resp = await client.getUserProfile(client.userID!);
final analyticsProfile =
AnalyticsProfileModel.fromJson(resp.additionalProperties);
_syncController?.dispose();
_syncController = AnalyticsSyncController(
client: client,
dataService: this,
);
await updateXPOffset(analyticsProfile.xpOffset ?? 0);
await _syncController!.bulkUpdate();
_syncController!.start();
await _initMergeTable();
} catch (e, s) {
Logs().e("Error initializing analytics: $e, $s");
} finally {
Logs().i("Analytics database initialized.");
_initCompleter.complete();
updateDispatcher.sendConstructAnalyticsUpdate(AnalyticsUpdate([]));
}
}
Future<void> _initMergeTable() async {
final vocab = await _analyticsClientGetter.database
.getAggregatedConstructs(ConstructTypeEnum.vocab);
final morph = await _analyticsClientGetter.database
.getAggregatedConstructs(ConstructTypeEnum.morph);
final blocked = blockedConstructs;
_mergeTable.addConstructs(vocab, blocked);
_mergeTable.addConstructs(morph, blocked);
}
Future<void> reinitialize() async {
Logs().i("Reinitializing analytics database.");
_initCompleter = Completer<void>();
await _initDatabase(_analyticsClientGetter.client);
}
Future<void> _clearDatabase() async {
await _analyticsClient?.database.clear();
_invalidateCaches();
_mergeTable.clear();
}
Future<void> _closeDatabase() async {
await _analyticsClient?.database.delete();
_analyticsClient = null;
_invalidateCaches();
_mergeTable.clear();
}
Future<void> _ensureInitialized() =>
_initCompleter.isCompleted ? Future.value() : _initCompleter.future;
int numConstructs(ConstructTypeEnum type) =>
_mergeTable.uniqueConstructsByType(type);
bool hasUsedConstruct(ConstructIdentifier id) =>
_mergeTable.constructUsed(id);
int uniqueConstructsByType(ConstructTypeEnum type) =>
_mergeTable.uniqueConstructsByType(type);
Set<ConstructIdentifier> get blockedConstructs {
final analyticsRoom = _analyticsClientGetter.client.analyticsRoomLocal();
return analyticsRoom?.blockedConstructs ?? {};
}
Future<void> waitForSync() async {
await _syncController?.syncStream.stream.first;
}
Future<DerivedAnalyticsDataModel> get derivedData async {
await _ensureInitialized();
if (_cachedDerivedStats == null || _derivedCacheVersion != _cacheVersion) {
_cachedDerivedStats =
await _analyticsClientGetter.database.getDerivedStats();
_derivedCacheVersion = _cacheVersion;
}
return _cachedDerivedStats!;
}
Future<DateTime?> getLastUpdatedAnalytics() async {
return _analyticsClientGetter.database.getLastEventTimestamp();
}
Future<List<OneConstructUse>> getUses({
int? count,
String? roomId,
DateTime? since,
}) async {
await _ensureInitialized();
final uses = await _analyticsClientGetter.database.getUses(
count: count,
roomId: roomId,
since: since,
);
final blocked = blockedConstructs;
return uses.where((use) => !blocked.contains(use.identifier)).toList();
}
Future<List<OneConstructUse>> getLocalUses() async {
await _ensureInitialized();
return _analyticsClientGetter.database.getLocalUses();
}
Future<int> getLocalConstructCount() async {
await _ensureInitialized();
return _analyticsClientGetter.database.getLocalConstructCount();
}
Future<ConstructUses> getConstructUse(ConstructIdentifier id) async {
await _ensureInitialized();
final blocked = blockedConstructs;
final ids = _mergeTable.groupedIds(id, blocked);
if (ids.isEmpty) {
return ConstructUses(
uses: [],
constructType: id.type,
lemma: id.lemma,
category: id.category,
);
}
return _analyticsClientGetter.database.getConstructUse(ids);
}
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
List<ConstructIdentifier> ids,
) async {
await _ensureInitialized();
final Map<ConstructIdentifier, List<ConstructIdentifier>> request = {};
final blocked = blockedConstructs;
for (final id in ids) {
if (blocked.contains(id)) continue;
request[id] = _mergeTable.groupedIds(id, blocked);
}
return _analyticsClientGetter.database.getConstructUses(request);
}
Future<Map<ConstructIdentifier, ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
) async {
await _ensureInitialized();
final combined =
await _analyticsClientGetter.database.getAggregatedConstructs(type);
final stopwatch = Stopwatch()..start();
final cleaned = <ConstructIdentifier, ConstructUses>{};
final blocked = blockedConstructs;
for (final entry in combined) {
final canonical = _mergeTable.resolve(entry.id);
// Insert or merge
final existing = cleaned[canonical];
if (existing != null) {
existing.merge(entry);
} else if (!blocked.contains(canonical)) {
cleaned[canonical] = entry;
}
}
stopwatch.stop();
Logs().i(
"Merging analytics took: ${stopwatch.elapsedMilliseconds} ms, total constructs: ${cleaned.length}",
);
return cleaned;
}
Future<int> getNewConstructCount(
List<OneConstructUse> newConstructs,
ConstructTypeEnum type,
) async {
await _ensureInitialized();
final blocked = blockedConstructs;
final uses = newConstructs
.where(
(c) => c.constructType == type && !blocked.contains(c.identifier),
)
.toList();
final Map<ConstructIdentifier, int> constructPoints = {};
for (final use in uses) {
constructPoints[use.identifier] ??= 0;
constructPoints[use.identifier] =
constructPoints[use.identifier]! + use.xp;
}
final constructs = await getConstructUses(constructPoints.keys.toList());
int newConstructCount = 0;
for (final entry in constructPoints.entries) {
final construct = constructs[entry.key]!;
if (construct.points == entry.value) {
newConstructCount++;
}
}
return newConstructCount;
}
Future<void> updateXPOffset(int offset) async {
_invalidateCaches();
await _analyticsClientGetter.database.updateXPOffset(offset);
}
Future<List<AnalyticsUpdateEvent>> updateLocalAnalytics(
AnalyticsUpdate update,
) async {
final events = <AnalyticsUpdateEvent>[];
final morphIds = update.addedConstructs
.where((c) => c.constructType == ConstructTypeEnum.morph)
.map((c) => c.identifier)
.toSet();
final prevData = await derivedData;
final prevMorphs = await getConstructUses(morphIds.toList());
_invalidateCaches();
await _ensureInitialized();
await _analyticsClientGetter.database.updateLocalAnalytics(
update.addedConstructs,
);
final blocked = blockedConstructs;
_mergeTable.addConstructsByUses(update.addedConstructs, blocked);
final newData = await derivedData;
// 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.
// Do this on all updates (not just on level updates) to account for cases
// of target language updates being missed (https://github.com/pangeachat/client/issues/2006)
MatrixState.pangeaController.userController.updateAnalyticsProfile(
level: newData.level,
);
if (newData.level > prevData.level) {
events.add(LevelUpEvent(prevData.level, newData.level));
} else if (newData.level < prevData.level) {
final lowerLevelXP = DerivedAnalyticsDataModel.calculateXpWithLevel(
prevData.level,
);
final offset = lowerLevelXP - newData.totalXP;
await MatrixState.pangeaController.userController.addXPOffset(offset);
await updateXPOffset(
MatrixState.pangeaController.userController.analyticsProfile!.xpOffset!,
);
}
final newMorphs = await getConstructUses(morphIds.toList());
final newUnlockedMorphs = morphIds.where((id) {
final prevPoints = prevMorphs[id]?.points ?? 0;
final newPoints = newMorphs[id]?.points ?? 0;
return prevPoints < _morphUnlockXP && newPoints >= _morphUnlockXP;
}).toSet();
if (newUnlockedMorphs.isNotEmpty) {
events.add(MorphUnlockedEvent(newUnlockedMorphs));
}
final points = update.addedConstructs.fold(0, (s, c) => s + c.xp);
events.add(XPGainedEvent(points, update.targetID));
if (update.blockedConstruct != null) {
events.add(ConstructBlockedEvent(update.blockedConstruct!));
}
return events;
}
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
) async {
_invalidateCaches();
final blocked = blockedConstructs;
for (final event in events) {
_mergeTable.addConstructsByUses(
event.content.uses,
blocked,
);
}
await _analyticsClientGetter.database.updateServerAnalytics(events);
}
Future<void> updateBlockedConstructs(
ConstructIdentifier constructId,
) async {
await _ensureInitialized();
_mergeTable.removeConstruct(constructId);
final construct =
await _analyticsClientGetter.database.getConstructUse([constructId]);
final derived = await derivedData;
final newXP = derived.totalXP - construct.points;
final newLevel = DerivedAnalyticsDataModel.calculateLevelWithXp(newXP);
await MatrixState.pangeaController.userController.updateAnalyticsProfile(
level: newLevel,
);
await _analyticsClientGetter.database.updateDerivedStats(
DerivedAnalyticsDataModel(totalXP: newXP),
);
_invalidateCaches();
updateDispatcher.sendConstructAnalyticsUpdate(
AnalyticsUpdate(
[],
blockedConstruct: constructId,
),
);
}
Future<void> clearLocalAnalytics() async {
_invalidateCaches();
await _ensureInitialized();
await _analyticsClientGetter.database.clearLocalConstructData();
}
}

View file

@ -0,0 +1,698 @@
// ignore_for_file: implementation_imports, depend_on_referenced_packages
import 'dart:async';
import 'dart:math';
import 'package:matrix/matrix.dart';
import 'package:sqflite_common/sqflite.dart';
import 'package:synchronized/synchronized.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_model.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:matrix/src/database/database_file_storage_stub.dart'
if (dart.library.io) 'package:matrix/src/database/database_file_storage_io.dart';
import 'package:matrix/src/database/indexeddb_box.dart'
if (dart.library.io) 'package:matrix/src/database/sqflite_box.dart';
class AnalyticsDatabase with DatabaseFileStorage {
final String name;
late BoxCollection _collection;
late Box<String> _lastEventTimestampBox;
late Box<List> _serverConstructsBox;
late Box<List> _localConstructsBox;
late Box<Map> _aggregatedServerVocabConstructsBox;
late Box<Map> _aggregatedLocalVocabConstructsBox;
late Box<Map> _aggregatedServerMorphConstructsBox;
late Box<Map> _aggregatedLocalMorphConstructsBox;
late Box<Map> _derivedServerStatsBox;
late Box<Map> _derivedLocalStatsBox;
static const String _serverConstructsBoxName = 'box_server_constructs';
static const String _localConstructsBoxName = 'box_local_constructs';
/// Key is Tuple of construct lemma, type, and category
static const String _aggregatedServerVocabConstructsBoxName =
'box_aggregated_server_vocab_constructs';
static const String _aggregatedLocalVocabConstructsBoxName =
'box_aggregated_local_vocab_constructs';
static const String _aggregatedServerMorphConstructsBoxName =
'box_aggregated_server_morph_constructs';
static const String _aggregatedLocalMorphConstructsBoxName =
'box_aggregated_local_morph_constructs';
static const String _derivedServerStatsBoxName = 'box_derived_server_stats';
static const String _derivedLocalStatsBoxName = 'box_derived_local_stats';
static const String _lastEventTimestampBoxName = 'box_last_event_timestamp';
Database? database;
/// Custom IdbFactory used to create the indexedDB. On IO platforms it would
/// lead to an error to import "dart:indexed_db" so this is dynamically
/// typed.
final dynamic idbFactory;
/// Custom SQFlite Database Factory used for high level operations on IO
/// like delete. Set it if you want to use sqlite FFI.
final DatabaseFactory? sqfliteFactory;
static Future<AnalyticsDatabase> init(
String name, {
Database? database,
dynamic idbFactory,
DatabaseFactory? sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) async {
final analyticsDatabase = AnalyticsDatabase._(
name,
database: database,
idbFactory: idbFactory,
sqfliteFactory: sqfliteFactory,
fileStorageLocation: fileStorageLocation,
deleteFilesAfterDuration: deleteFilesAfterDuration,
);
await analyticsDatabase.open();
return analyticsDatabase;
}
AnalyticsDatabase._(
this.name, {
this.database,
this.idbFactory,
this.sqfliteFactory,
Uri? fileStorageLocation,
Duration? deleteFilesAfterDuration,
}) {
this.fileStorageLocation = fileStorageLocation;
this.deleteFilesAfterDuration = deleteFilesAfterDuration;
}
final _lock = Lock();
Future<void> open() async {
_collection = await BoxCollection.open(
name,
{
_lastEventTimestampBoxName,
_serverConstructsBoxName,
_localConstructsBoxName,
_aggregatedServerVocabConstructsBoxName,
_aggregatedLocalVocabConstructsBoxName,
_aggregatedServerMorphConstructsBoxName,
_aggregatedLocalMorphConstructsBoxName,
_derivedServerStatsBoxName,
_derivedLocalStatsBoxName,
},
sqfliteDatabase: database,
sqfliteFactory: sqfliteFactory,
idbFactory: idbFactory,
version: MatrixSdkDatabase.version,
);
_lastEventTimestampBox = _collection.openBox<String>(
_lastEventTimestampBoxName,
);
_serverConstructsBox = _collection.openBox<List>(
_serverConstructsBoxName,
);
_localConstructsBox = _collection.openBox<List>(
_localConstructsBoxName,
);
_aggregatedServerVocabConstructsBox = _collection.openBox<Map>(
_aggregatedServerVocabConstructsBoxName,
);
_aggregatedLocalVocabConstructsBox = _collection.openBox<Map>(
_aggregatedLocalVocabConstructsBoxName,
);
_aggregatedServerMorphConstructsBox = _collection.openBox<Map>(
_aggregatedServerMorphConstructsBoxName,
);
_aggregatedLocalMorphConstructsBox = _collection.openBox<Map>(
_aggregatedLocalMorphConstructsBoxName,
);
_derivedServerStatsBox = _collection.openBox<Map>(
_derivedServerStatsBoxName,
);
_derivedLocalStatsBox = _collection.openBox<Map>(
_derivedLocalStatsBoxName,
);
}
Future<void> delete() async {
await _collection.deleteDatabase(
database?.path ?? name,
sqfliteFactory ?? idbFactory,
);
}
Future<void> clear() async {
_lastEventTimestampBox.clearQuickAccessCache();
_serverConstructsBox.clearQuickAccessCache();
_localConstructsBox.clearQuickAccessCache();
_aggregatedServerVocabConstructsBox.clearQuickAccessCache();
_aggregatedLocalVocabConstructsBox.clearQuickAccessCache();
_aggregatedServerMorphConstructsBox.clearQuickAccessCache();
_aggregatedLocalMorphConstructsBox.clearQuickAccessCache();
_derivedServerStatsBox.clearQuickAccessCache();
_derivedLocalStatsBox.clearQuickAccessCache();
await _collection.clear();
}
Future<T> _transaction<T>(Future<T> Function() action) {
return _lock.synchronized(action);
}
Box<Map> _aggBox(ConstructTypeEnum type, bool local) =>
switch ((type, local)) {
(ConstructTypeEnum.vocab, true) => _aggregatedLocalVocabConstructsBox,
(ConstructTypeEnum.vocab, false) => _aggregatedServerVocabConstructsBox,
(ConstructTypeEnum.morph, true) => _aggregatedLocalMorphConstructsBox,
(ConstructTypeEnum.morph, false) => _aggregatedServerMorphConstructsBox,
};
Future<DateTime?> getLastEventTimestamp() async {
final timestampString =
await _lastEventTimestampBox.get('last_event_timestamp');
if (timestampString == null) return null;
return DateTime.parse(timestampString);
}
Future<DerivedAnalyticsDataModel> _getDerivedServerStats() async {
final raw = await _derivedServerStatsBox.get('derived_stats');
return raw == null
? DerivedAnalyticsDataModel()
: DerivedAnalyticsDataModel.fromJson(
Map<String, dynamic>.from(raw),
);
}
Future<DerivedAnalyticsDataModel> _getDerivedLocalStats() async {
final raw = await _derivedLocalStatsBox.get('derived_stats');
return raw == null
? DerivedAnalyticsDataModel()
: DerivedAnalyticsDataModel.fromJson(
Map<String, dynamic>.from(raw),
);
}
Future<DerivedAnalyticsDataModel> getDerivedStats() async {
DerivedAnalyticsDataModel server = DerivedAnalyticsDataModel();
DerivedAnalyticsDataModel local = DerivedAnalyticsDataModel();
server = await _getDerivedServerStats();
local = await _getDerivedLocalStats();
return server.merge(local);
}
Future<List<OneConstructUse>> getUses({
int? count,
String? roomId,
DateTime? since,
}) async {
final stopwatch = Stopwatch()..start();
final List<OneConstructUse> uses = [];
// first, get all of the local (most recent) keys
final localKeys = await _localConstructsBox.getAllKeys();
final localValues = await _localConstructsBox.getAll(localKeys);
final local = Map.fromIterables(
localKeys,
localValues,
).entries.toList();
local.sort(
(a, b) => int.parse(b.key).compareTo(int.parse(a.key)),
);
for (final entry in local) {
// filter by date
if (since != null &&
int.parse(entry.key) < since.millisecondsSinceEpoch) {
continue;
}
final rawUses = entry.value;
if (rawUses == null) continue;
for (final raw in rawUses) {
// filter by count
if (count != null && uses.length >= count) break;
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
// filter by roomID
if (roomId != null && use.metadata.roomId != roomId) {
continue;
}
uses.add(use);
}
if (count != null && uses.length >= count) break;
}
if (count != null && uses.length >= count) return uses;
// then get server uses
final serverKeys = await _serverConstructsBox.getAllKeys();
serverKeys.sort(
(a, b) =>
int.parse(b.split('|')[1]).compareTo(int.parse(a.split('|')[1])),
);
for (final key in serverKeys) {
// filter by count
if (count != null && uses.length >= count) break;
final rawUses = await _serverConstructsBox.get(key);
if (rawUses == null) continue;
for (final raw in rawUses) {
if (count != null && uses.length >= count) break;
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
// filter by roomID
if (roomId != null && use.metadata.roomId != roomId) {
continue;
}
// filter by date
if (since != null && use.timeStamp.isBefore(since)) {
continue;
}
uses.add(use);
}
}
stopwatch.stop();
Logs().i("Get uses took ${stopwatch.elapsedMilliseconds} ms");
return uses.take(count ?? uses.length).toList();
}
Future<List<OneConstructUse>> getLocalUses() async {
final List<OneConstructUse> uses = [];
final localKeys = await _localConstructsBox.getAllKeys();
final localValues = await _localConstructsBox.getAll(localKeys);
for (final rawList in localValues) {
if (rawList == null) continue;
for (final raw in rawList) {
final use = OneConstructUse.fromJson(
Map<String, dynamic>.from(raw),
);
uses.add(use);
}
}
return uses;
}
Future<int> getLocalConstructCount() async {
final keys = await _localConstructsBox.getAllKeys();
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 {
assert(ids.isNotEmpty);
final ConstructUses construct = ConstructUses(
uses: [],
constructType: ids.first.type,
lemma: ids.first.lemma,
category: ids.first.category,
);
for (final id in ids) {
final key = id.storageKey;
ConstructUses? server;
ConstructUses? local;
final serverBox = _aggBox(id.type, false);
final localBox = _aggBox(id.type, true);
final serverRaw = await serverBox.get(key);
if (serverRaw != null) {
server = ConstructUses.fromJson(
Map<String, dynamic>.from(serverRaw),
);
}
final localRaw = await localBox.get(key);
if (localRaw != null) {
local = ConstructUses.fromJson(
Map<String, dynamic>.from(localRaw),
);
}
if (server != null) construct.merge(server);
if (local != null) construct.merge(local);
}
return construct;
}
Future<Map<ConstructIdentifier, ConstructUses>> getConstructUses(
Map<ConstructIdentifier, List<ConstructIdentifier>> ids,
) async {
final Map<ConstructIdentifier, ConstructUses> results = {};
for (final entry in ids.entries) {
final construct = await getConstructUse(entry.value);
results[entry.key] = construct;
}
return results;
}
Future<void> clearLocalConstructData() async {
await _transaction(() async {
await _localConstructsBox.clear();
await _aggregatedLocalVocabConstructsBox.clear();
await _aggregatedLocalMorphConstructsBox.clear();
await _derivedLocalStatsBox.clear();
});
}
/// Group uses by aggregate key
Map<String, List<OneConstructUse>> _groupUses(
List<OneConstructUse> uses,
) {
final Map<String, List<OneConstructUse>> grouped = {};
for (final u in uses) {
final key = u.identifier.storageKey;
(grouped[key] ??= []).add(u);
}
return grouped;
}
Map<String, ConstructUses> _aggregateConstructs(
Map<String, List<OneConstructUse>> groups,
Map<String, Map<dynamic, dynamic>?> existingRaw,
) {
final Map<String, ConstructUses> updates = {};
for (final entry in groups.entries) {
final key = entry.key;
final usesForKey = entry.value;
final raw = existingRaw[key];
ConstructUses model;
if (raw is Map<String, dynamic>) {
model = ConstructUses.fromJson(raw);
} else {
final u = usesForKey.first;
model = ConstructUses(
uses: [],
constructType: u.constructType,
lemma: u.lemma,
category: u.category,
);
}
for (final u in usesForKey) {
model.uses.add(u);
model.setLastUsed(u.timeStamp);
}
updates[key] = model;
}
return updates;
}
Future<Map<String, ConstructUses>> _aggregateFromBox(
Box<Map> box,
Map<String, List<OneConstructUse>> grouped,
) async {
final keys = grouped.keys.toList();
final existing = await box.getAll(keys);
final existingMap = Map.fromIterables(keys, existing);
return _aggregateConstructs(grouped, existingMap);
}
Future<List<ConstructUses>> getAggregatedConstructs(
ConstructTypeEnum type,
) async {
Map<String, ConstructUses> combined = {};
final stopwatch = Stopwatch()..start();
final localKeys = await _aggBox(type, true).getAllKeys();
final serverKeys = await _aggBox(type, false).getAllKeys();
final serverValues = await _aggBox(type, false).getAll(serverKeys);
final serverConstructs = serverValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final serverAgg = Map.fromIterables(
serverKeys,
serverConstructs,
);
if (localKeys.isEmpty) {
combined = serverAgg;
} else {
final localValues = await _aggBox(type, true).getAll(localKeys);
final localConstructs = localValues
.map((e) => ConstructUses.fromJson(Map<String, dynamic>.from(e!)))
.toList();
final localAgg = Map.fromIterables(
localKeys,
localConstructs,
);
combined = Map<String, ConstructUses>.from(serverAgg);
for (final entry in localAgg.entries) {
final key = entry.key;
final localModel = entry.value;
if (combined.containsKey(key)) {
final serverModel = combined[key]!;
serverModel.merge(localModel);
combined[key] = serverModel;
} else {
combined[key] = localModel;
}
}
}
stopwatch.stop();
Logs().i(
"Combining aggregates took ${stopwatch.elapsedMilliseconds} ms",
);
return combined.values.toList();
}
Future<void> updateXPOffset(int offset) {
return _transaction(() async {
final serverStats = await _getDerivedServerStats();
final localStats = await _getDerivedLocalStats();
final updatedServerStats = serverStats.copyWith(
offset: offset,
);
final updatedLocalStats = localStats.copyWith(
offset: offset,
);
await _derivedServerStatsBox.put(
'derived_stats',
updatedServerStats.toJson(),
);
await _derivedLocalStatsBox.put(
'derived_stats',
updatedLocalStats.toJson(),
);
});
}
Future<void> updateDerivedStats(DerivedAnalyticsDataModel newStats) =>
_derivedServerStatsBox.put(
'derived_stats',
newStats.toJson(),
);
Future<void> updateServerAnalytics(
List<ConstructAnalyticsEvent> events,
) async {
if (events.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
final lastUpdated = await getLastEventTimestamp();
final derivedData = await _getDerivedServerStats();
DateTime mostRecent = lastUpdated ?? events.first.event.originServerTs;
final existingKeys = (await _serverConstructsBox.getAllKeys()).toSet();
final List<OneConstructUse> aggregatedVocabUses = [];
final List<OneConstructUse> aggregatedMorphUses = [];
final Map<String, List<OneConstructUse>> pendingWrites = {};
for (final event in events) {
final ts = event.event.originServerTs;
final key = TupleKey(
event.event.eventId,
ts.millisecondsSinceEpoch.toString(),
).toString();
if (lastUpdated != null && ts.isBefore(lastUpdated)) continue;
if (existingKeys.contains(key)) continue;
if (ts.isAfter(mostRecent)) mostRecent = ts;
pendingWrites[key] = event.content.uses;
for (final u in event.content.uses) {
u.constructType == ConstructTypeEnum.vocab
? aggregatedVocabUses.add(u)
: aggregatedMorphUses.add(u);
}
}
if (pendingWrites.isEmpty) return;
// Write events sequentially
for (final e in pendingWrites.entries) {
_serverConstructsBox.put(
e.key,
e.value.map((u) => u.toJson()).toList(),
);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedServerVocabConstructsBox,
_groupUses(aggregatedVocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedServerVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedServerMorphConstructsBox,
_groupUses(aggregatedMorphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedServerMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
// Update derived stats
final updatedDerivedStats = derivedData.update(
[
...aggregatedVocabUses,
...aggregatedMorphUses,
],
);
await _derivedServerStatsBox.put(
'derived_stats',
updatedDerivedStats.toJson(),
);
// Update timestamp
await _lastEventTimestampBox.put(
'last_event_timestamp',
mostRecent.toIso8601String(),
);
});
stopwatch.stop();
Logs().i(
"Server analytics update took ${stopwatch.elapsedMilliseconds} ms",
);
}
Future<void> updateLocalAnalytics(
List<OneConstructUse> uses,
) async {
if (uses.isEmpty) return;
final stopwatch = Stopwatch()..start();
await _transaction(() async {
// Store local constructs
final key = DateTime.now().millisecondsSinceEpoch;
_localConstructsBox.put(
key.toString(),
uses.map((u) => u.toJson()).toList(),
);
final List<OneConstructUse> vocabUses = [];
final List<OneConstructUse> morphUses = [];
for (final u in uses) {
u.constructType == ConstructTypeEnum.vocab
? vocabUses.add(u)
: morphUses.add(u);
}
// Update aggregates
final aggVocabUpdates = await _aggregateFromBox(
_aggregatedLocalVocabConstructsBox,
_groupUses(vocabUses),
);
for (final entry in aggVocabUpdates.entries) {
await _aggregatedLocalVocabConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
final aggMorphUpdates = await _aggregateFromBox(
_aggregatedLocalMorphConstructsBox,
_groupUses(morphUses),
);
for (final entry in aggMorphUpdates.entries) {
await _aggregatedLocalMorphConstructsBox.put(
entry.key,
entry.value.toJson(),
);
}
// Update derived stats
final derivedData = await _getDerivedLocalStats();
final updatedDerivedStats = derivedData.update(uses);
await _derivedLocalStatsBox.put(
'derived_stats',
updatedDerivedStats.toJson(),
);
});
stopwatch.stop();
Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms");
}
}

View file

@ -0,0 +1,95 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/pangea/analytics_data/analytics_database.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/flutter_matrix_dart_sdk_database/sqlcipher_stub.dart';
import 'package:fluffychat/utils/platform_infos.dart';
Future<AnalyticsDatabase> analyticsDatabaseBuilder(String name) async {
AnalyticsDatabase? database;
try {
database = await _constructDatabase(name);
await database.open();
return database;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"clientID": name},
m: "Failed to open analytics database. Opening fallback database.",
);
Logs().wtf('Unable to construct database!', e, s);
// Try to delete database so that it can created again on next init:
database?.delete().catchError((err, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {},
m: "Failed to delete analytics database after failed construction.",
);
});
// Delete database file:
if (database == null && !kIsWeb) {
final dbFile = File(await _getDatabasePath(name));
if (await dbFile.exists()) await dbFile.delete();
}
rethrow;
}
}
Future<AnalyticsDatabase> _constructDatabase(String name) async {
if (kIsWeb) {
html.window.navigator.storage?.persist();
return await AnalyticsDatabase.init(name);
}
Directory? fileStorageLocation;
try {
fileStorageLocation = await getTemporaryDirectory();
} on MissingPlatformDirectoryException catch (_) {
Logs().w(
'No temporary directory for file cache available on this platform.',
);
}
final path = await _getDatabasePath(name);
// fix dlopen for old Android
await applyWorkaroundToOpenSqlCipherOnOldAndroidVersions();
// import the SQLite / SQLCipher shared objects / dynamic libraries
final factory =
createDatabaseFactoryFfi(ffiInit: SQfLiteEncryptionHelper.ffiInit);
// required for [getDatabasesPath]
databaseFactory = factory;
final database = await factory.openDatabase(
path,
options: OpenDatabaseOptions(version: 1),
);
return await AnalyticsDatabase.init(
name,
database: database,
fileStorageLocation: fileStorageLocation?.uri,
deleteFilesAfterDuration: const Duration(days: 30),
);
}
Future<String> _getDatabasePath(String name) async {
final databaseDirectory = PlatformInfos.isIOS || PlatformInfos.isMacOS
? await getLibraryDirectory()
: await getApplicationSupportDirectory();
return join(databaseDirectory.path, '$name.sqlite');
}

View file

@ -0,0 +1,85 @@
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsSyncController {
final Client client;
final AnalyticsDataService dataService;
StreamSubscription? _subscription;
StreamController<List<String>> syncStream =
StreamController<List<String>>.broadcast();
AnalyticsSyncController({
required this.client,
required this.dataService,
});
void start() {
_subscription ??= client.onSync.stream.listen(_onSync);
}
void dispose() {
_subscription?.cancel();
_subscription = null;
syncStream.close();
}
Future<void> _onSync(SyncUpdate update) async {
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,
);
if (events == null || events.isEmpty) return;
final constructEvents = events
.map(
(e) => ConstructAnalyticsEvent(
event: Event.fromMatrixEvent(e, analyticsRoom),
),
)
.where((e) => e.event.status == EventStatus.synced)
.toList();
if (constructEvents.isEmpty) return;
await dataService.updateServerAnalytics(constructEvents);
syncStream.add(
List<String>.from(constructEvents.map((e) => e.event.eventId)),
);
}
Future<void> bulkUpdate() async {
final analyticsRoom = _getAnalyticsRoom();
if (analyticsRoom == null) return;
final lastUpdated = await dataService.getLastUpdatedAnalytics();
final events = await analyticsRoom.getAnalyticsEvents(
userId: client.userID!,
since: lastUpdated,
);
if (events == null || events.isEmpty) return;
await dataService.updateServerAnalytics(events);
}
Room? _getAnalyticsRoom() {
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 == null) return null;
return client.analyticsRoomLocal(l2);
}
}

View file

@ -0,0 +1,140 @@
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_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
class LevelUpdate {
final int prevLevel;
final int newLevel;
LevelUpdate({
required this.prevLevel,
required this.newLevel,
});
}
class AnalyticsUpdate {
final List<OneConstructUse> addedConstructs;
final ConstructIdentifier? blockedConstruct;
final String? targetID;
AnalyticsUpdate(
this.addedConstructs, {
this.blockedConstruct,
this.targetID,
});
}
class AnalyticsUpdateDispatcher {
final AnalyticsDataService dataService;
final StreamController<AnalyticsStreamUpdate> constructUpdateStream =
StreamController<AnalyticsStreamUpdate>.broadcast();
final StreamController<String> activityAnalyticsStream =
StreamController<String>.broadcast();
final StreamController<Set<ConstructIdentifier>> unlockedConstructsStream =
StreamController<Set<ConstructIdentifier>>.broadcast();
final StreamController<LevelUpdate> levelUpdateStream =
StreamController<LevelUpdate>.broadcast();
final StreamController<MapEntry<ConstructIdentifier, UserSetLemmaInfo>>
_lemmaInfoUpdateStream = StreamController<
MapEntry<ConstructIdentifier, UserSetLemmaInfo>>.broadcast();
AnalyticsUpdateDispatcher(this.dataService);
void dispose() {
constructUpdateStream.close();
activityAnalyticsStream.close();
unlockedConstructsStream.close();
levelUpdateStream.close();
_lemmaInfoUpdateStream.close();
}
Stream<UserSetLemmaInfo> lemmaUpdateStream(
ConstructIdentifier constructId,
) =>
_lemmaInfoUpdateStream.stream
.where((update) => update.key == constructId)
.map((update) => update.value);
void sendActivityAnalyticsUpdate(
String activityAnalytics,
) =>
activityAnalyticsStream.add(activityAnalytics);
void sendLemmaInfoUpdate(
ConstructIdentifier constructId,
UserSetLemmaInfo lemmaInfo,
) =>
_lemmaInfoUpdateStream.add(MapEntry(constructId, lemmaInfo));
Future<void> sendConstructAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
) async {
final events = await dataService.updateLocalAnalytics(analyticsUpdate);
for (final event in events) {
_dispatch(event);
}
}
void _dispatch(AnalyticsUpdateEvent event) {
switch (event) {
case final LevelUpEvent e:
_onLevelUp(e.from, e.to);
break;
case final MorphUnlockedEvent e:
_onUnlockMorphLemmas(e.unlocked);
break;
case final XPGainedEvent e:
_onXPGained(e.points, e.targetID);
break;
case final ConstructBlockedEvent e:
_onBlockedConstruct(e.blockedConstruct);
break;
}
}
void _onLevelUp(final int lowerLevel, final int upperLevel) {
levelUpdateStream.add(
LevelUpdate(
prevLevel: lowerLevel,
newLevel: upperLevel,
),
);
}
void _onUnlockMorphLemmas(Set<ConstructIdentifier> unlocked) {
const excludedLemmas = {'not_proper'};
final filtered = {
for (final id in unlocked)
if (!excludedLemmas.contains(id.lemma.toLowerCase())) id,
};
if (filtered.isNotEmpty) {
unlockedConstructsStream.add(filtered);
}
}
void _onXPGained(int points, String? targetID) {
final update = AnalyticsStreamUpdate(
points: points,
targetID: targetID,
);
constructUpdateStream.add(update);
}
void _onBlockedConstruct(ConstructIdentifier constructId) {
final update = AnalyticsStreamUpdate(
blockedConstruct: constructId,
);
constructUpdateStream.add(update);
}
}

View file

@ -0,0 +1,25 @@
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
sealed class AnalyticsUpdateEvent {}
class LevelUpEvent extends AnalyticsUpdateEvent {
final int from;
final int to;
LevelUpEvent(this.from, this.to);
}
class MorphUnlockedEvent extends AnalyticsUpdateEvent {
final Set<ConstructIdentifier> unlocked;
MorphUnlockedEvent(this.unlocked);
}
class XPGainedEvent extends AnalyticsUpdateEvent {
final int points;
final String? targetID;
XPGainedEvent(this.points, this.targetID);
}
class ConstructBlockedEvent extends AnalyticsUpdateEvent {
final ConstructIdentifier blockedConstruct;
ConstructBlockedEvent(this.blockedConstruct);
}

View file

@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
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/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/user/user_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsUpdateService {
static const int _maxMessagesCached = 10;
final AnalyticsDataService dataService;
AnalyticsUpdateService(this.dataService);
Completer<void>? _updateCompleter;
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
Future<Room?> _getAnalyticsRoom() async {
final l2 = _l2;
if (l2 == null) return null;
final analyticsRoom = await dataService.getAnalyticsRoom(l2);
return analyticsRoom;
}
Future<void> onUpdateLanguages(LanguageUpdate update) async {
await sendLocalAnalyticsToAnalyticsRoom(
l2Override: update.prevTargetLang,
);
await dataService.reinitialize();
final data = await dataService.derivedData;
MatrixState.pangeaController.userController
.updateAnalyticsProfile(level: data.level);
}
Future<void> addAnalytics(
String? targetID,
List<OneConstructUse> newConstructs,
) async {
await dataService.updateDispatcher.sendConstructAnalyticsUpdate(
AnalyticsUpdate(
newConstructs,
targetID: targetID,
),
);
final localConstructCount = await dataService.getLocalConstructCount();
final lastUpdated = await dataService.getLastUpdatedAnalytics();
final difference = DateTime.now().difference(lastUpdated ?? DateTime.now());
if (localConstructCount > _maxMessagesCached || difference.inMinutes > 10) {
sendLocalAnalyticsToAnalyticsRoom();
}
}
Future<void> sendLocalAnalyticsToAnalyticsRoom({
LanguageModel? l2Override,
}) async {
final inProgress =
_updateCompleter != null && !_updateCompleter!.isCompleted;
if (inProgress) {
await _updateCompleter!.future;
return;
}
_updateCompleter = Completer<void>();
try {
await _updateAnalytics(l2Override: l2Override);
await dataService.clearLocalAnalytics();
} catch (err, s) {
ErrorHandler.logError(
e: err,
m: "Failed to update analytics",
s: s,
data: {
"l2Override": l2Override,
},
);
} finally {
_updateCompleter?.complete();
_updateCompleter = null;
}
}
Future<void> _updateAnalytics({LanguageModel? l2Override}) async {
final localConstructs = await dataService.getLocalUses();
if (localConstructs.isEmpty) return;
final analyticsRoom = await _getAnalyticsRoom();
// and send cached analytics data to the room
final future = dataService.waitForSync();
await analyticsRoom?.sendConstructsEvent(localConstructs);
await future;
}
Future<void> sendActivityAnalytics(String roomId) async {
final analyticsRoom = await _getAnalyticsRoom();
if (analyticsRoom == null) return;
await analyticsRoom.addActivityRoomId(roomId);
dataService.updateDispatcher.sendActivityAnalyticsUpdate(roomId);
}
Future<void> blockConstruct(ConstructIdentifier constructId) async {
final analyticsRoom = await _getAnalyticsRoom();
if (analyticsRoom == null) return;
final current = analyticsRoom.analyticsSettings;
final blockedConstructs = current.blockedConstructs;
final updated = current.copyWith(
blockedConstructs: {
...blockedConstructs,
constructId,
},
);
await analyticsRoom.setAnalyticsSettings(updated);
await dataService.updateBlockedConstructs(constructId);
}
Future<void> setLemmaInfo(
ConstructIdentifier constructId, {
String? emoji,
String? meaning,
}) async {
final analyticsRoom = await _getAnalyticsRoom();
if (analyticsRoom == null) return;
final userLemmaInfo = analyticsRoom.getUserSetLemmaInfo(constructId);
final updated = userLemmaInfo.copyWith(
emojis: emoji == null ? null : [emoji],
meaning: meaning,
);
if (userLemmaInfo == updated) return;
dataService.updateDispatcher.sendLemmaInfoUpdate(constructId, updated);
try {
await analyticsRoom.setUserSetLemmaInfo(constructId, updated);
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
data: userLemmaInfo.toJson(),
s: s,
);
}
}
}

View file

@ -0,0 +1,41 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/widgets/matrix.dart';
mixin AnalyticsUpdater<T extends StatefulWidget> on State<T> {
StreamSubscription? _analyticsSubscription;
@override
void initState() {
super.initState();
final updater = Matrix.of(context).analyticsDataService.updateDispatcher;
_analyticsSubscription =
updater.constructUpdateStream.stream.listen(_onAnalyticsUpdate);
}
@override
void dispose() {
_analyticsSubscription?.cancel();
super.dispose();
}
Future<void> addAnalytics(
List<OneConstructUse> constructs,
String? targetId,
) =>
Matrix.of(context).analyticsDataService.updateService.addAnalytics(
targetId,
constructs,
);
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID != null) {
OverlayUtil.showPointsGained(update.targetID!, update.points, context);
}
}
}

View file

@ -0,0 +1,140 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
class ConstructMergeTable {
Map<String, Set<ConstructIdentifier>> lemmaTypeGroups = {};
Map<ConstructIdentifier, ConstructIdentifier> otherToSpecific = {};
void addConstructs(
List<ConstructUses> constructs,
Set<ConstructIdentifier> exclude,
) {
addConstructsByUses(constructs.expand((c) => c.uses).toList(), exclude);
}
void addConstructsByUses(
List<OneConstructUse> uses,
Set<ConstructIdentifier> exclude,
) {
for (final use in uses) {
final id = use.identifier;
if (exclude.contains(id)) continue;
final composite = id.compositeKey;
(lemmaTypeGroups[composite] ??= {}).add(id);
}
for (final use in uses) {
if (exclude.contains(use.identifier)) continue;
final id = use.identifier;
final composite = id.compositeKey;
if (id.category == 'other' && !otherToSpecific.containsKey(id)) {
final specific = lemmaTypeGroups[composite]!.firstWhereOrNull(
(k) => k.category != 'other',
);
if (specific != null) {
otherToSpecific[id] = specific;
}
}
}
}
void removeConstruct(ConstructIdentifier id) {
final composite = id.compositeKey;
final group = lemmaTypeGroups[composite];
if (group == null) return;
group.remove(id);
if (group.isEmpty) {
lemmaTypeGroups.remove(composite);
}
if (id.category != 'other') {
final otherId = ConstructIdentifier(
lemma: id.lemma,
type: id.type,
category: 'other',
);
otherToSpecific.remove(otherId);
} else {
otherToSpecific.remove(id);
}
}
ConstructIdentifier resolve(ConstructIdentifier key) =>
otherToSpecific[key] ?? key;
List<ConstructIdentifier> groupedIds(
ConstructIdentifier id,
Set<ConstructIdentifier> exclude,
) {
final keys = <ConstructIdentifier>[];
if (!exclude.contains(id)) {
keys.add(id);
}
if (id.category == 'other') {
final specificKey = otherToSpecific[id];
if (specificKey != null) {
keys.add(specificKey);
}
return keys;
}
final group = lemmaTypeGroups[id.compositeKey];
if (group == null) return keys;
final otherEntry = group.firstWhereOrNull((k) => k.category == 'other');
if (otherEntry == null) return keys;
final otherSpecificEntry = otherToSpecific[otherEntry];
if (otherSpecificEntry == id) {
keys.add(
ConstructIdentifier(
lemma: id.lemma,
type: id.type,
category: 'other',
),
);
}
return keys;
}
int uniqueConstructsByType(ConstructTypeEnum type) {
final keys = lemmaTypeGroups.keys.where(
(composite) => composite.endsWith('|${type.name}'),
);
int count = 0;
for (final composite in keys) {
final group = lemmaTypeGroups[composite]!;
if (group.any((e) => e.category == 'other')) {
// if this is the only entry in the group, it's a unique construct
if (group.length == 1) {
count += 1;
continue;
}
// otherwise, count all but the 'other' entry,
// which is merged into a more specific construct
count += group.length - 1;
continue;
}
// all specific constructs, count them all
count += group.length;
}
return count;
}
bool constructUsed(ConstructIdentifier id) =>
lemmaTypeGroups[id.compositeKey]?.contains(id) ?? false;
void clear() {
lemmaTypeGroups.clear();
otherToSpecific.clear();
}
}

View file

@ -0,0 +1,109 @@
import 'dart:math';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
class DerivedAnalyticsDataModel {
final int _totalXP;
final int offset;
DerivedAnalyticsDataModel({
int totalXP = 0,
this.offset = 0,
}) : _totalXP = totalXP;
int get totalXP => _totalXP + offset;
int get level => calculateLevelWithXp(_totalXP);
// the minimum XP required for a given level
int get _minXPForLevel => calculateXpWithLevel(level);
// the minimum XP required for the next level
int get minXPForNextLevel => calculateXpWithLevel(level + 1);
// the progress within the current level as a percentage (0.0 to 1.0)
double get levelProgress {
final progress =
(_totalXP - _minXPForLevel) / (minXPForNextLevel - _minXPForLevel);
return progress >= 0 ? progress : 0;
}
static final double D = Environment.isStagingEnvironment ? 500 : 1500;
static int calculateXpWithLevel(int level) {
// If level <= 1, XP should be 0 or negative by this math.
// In practice, you might clamp it to 0:
if (level <= 1) {
return 0;
}
// Convert level to double for the math
final double lc = level.toDouble();
// XP from the inverse formula:
final double xpDouble = (D / 8.0) * (2.0 * pow(lc - 1.0, 2.0) - 1.0);
// Floor or clamp to ensure non-negative.
final int xp = xpDouble.floor();
return (xp < 0) ? 0 : xp;
}
static int calculateLevelWithXp(int totalXP) {
final doubleScore = (1 + sqrt((1 + (8.0 * totalXP / D)) / 2.0));
if (!doubleScore.isNaN && doubleScore.isFinite) {
return doubleScore.floor();
} else {
ErrorHandler.logError(
e: "Calculated level in Nan or Infinity",
data: {
"totalXP": totalXP,
"level": doubleScore,
},
);
return 1;
}
}
DerivedAnalyticsDataModel update(List<OneConstructUse> uses) {
int xp = _totalXP;
for (final u in uses) {
xp += u.xp;
}
return copyWith(
totalXP: xp,
);
}
DerivedAnalyticsDataModel merge(DerivedAnalyticsDataModel other) {
return DerivedAnalyticsDataModel(
totalXP: _totalXP + other.totalXP,
offset: offset,
);
}
DerivedAnalyticsDataModel copyWith({
int? totalXP,
int? offset,
}) {
return DerivedAnalyticsDataModel(
totalXP: totalXP ?? this.totalXP,
offset: offset ?? this.offset,
);
}
factory DerivedAnalyticsDataModel.fromJson(Map<String, dynamic> map) {
return DerivedAnalyticsDataModel(
totalXP: map['total_xp'] ?? 0,
);
}
Map<String, dynamic> toJson() {
return {
'total_xp': _totalXP,
};
}
}

View file

@ -0,0 +1,106 @@
import 'dart:async';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_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/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelUpAnalyticsService {
final Client client;
final Future<void> Function() ensureInitialized;
final AnalyticsDataService dataService;
const LevelUpAnalyticsService({
required this.client,
required this.ensureInitialized,
required this.dataService,
});
Future<ConstructSummary> getLevelUpAnalytics(
int lowerLevel,
int upperLevel,
DateTime? lastLevelUpTimestamp,
) async {
await ensureInitialized();
final uses = await dataService.getUses(since: lastLevelUpTimestamp);
final messages = await _buildMessageContext(uses);
final userController = MatrixState.pangeaController.userController;
final request = ConstructSummaryRequest(
constructs: uses,
messages: messages,
userL1: userController.userL1!.langCodeShort,
userL2: userController.userL2!.langCodeShort,
lowerLevel: lowerLevel,
upperLevel: upperLevel,
);
final response = await ConstructRepo.generateConstructSummary(request);
final summary = response.summary;
summary.levelVocabConstructs =
dataService.uniqueConstructsByType(ConstructTypeEnum.vocab);
summary.levelGrammarConstructs =
dataService.uniqueConstructsByType(ConstructTypeEnum.morph);
return summary;
}
Future<List<Map<String, dynamic>>> _buildMessageContext(
List<OneConstructUse> uses,
) async {
final Map<String, Set<String>> useEventIds = {};
for (final use in uses) {
final roomId = use.metadata.roomId;
final eventId = use.metadata.eventId;
if (roomId == null || eventId == null) continue;
useEventIds.putIfAbsent(roomId, () => {}).add(eventId);
}
final List<Map<String, dynamic>> messages = [];
for (final entry in useEventIds.entries) {
final room = client.getRoomById(entry.key);
if (room == null) continue;
final timeline = await room.getTimeline();
for (final eventId in entry.value) {
try {
final event = await room.getEventById(eventId);
if (event == null) continue;
final pangeaEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: room.client.userID == event.senderId,
);
messages.add({
'sent': pangeaEvent.originalSent?.text ?? pangeaEvent.body,
'written': pangeaEvent.originalWrittenContent,
});
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'roomId': entry.key,
'eventId': eventId,
},
);
}
}
}
return messages;
}
}

View file

@ -1,10 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_analytics_list_view.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_details_view.dart';
import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_details_view.dart';
import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_view.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
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';
@ -33,24 +37,61 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
MorphFeaturesAndTags morphs = defaultMorphMapping;
List<MorphFeature> features = defaultMorphMapping.displayFeatures;
List<ConstructUses>? vocab;
bool isSearching = false;
ConstructLevelEnum? selectedConstructLevel;
StreamSubscription<AnalyticsStreamUpdate>? _blockedConstructSub;
@override
void initState() {
super.initState();
_setMorphs();
_setVocab();
searchController.addListener(() {
if (mounted) setState(() {});
});
_blockedConstructSub = Matrix.of(context)
.analyticsDataService
.updateDispatcher
.constructUpdateStream
.stream
.listen(_onBlockConstruct);
}
@override
void dispose() {
searchController.dispose();
_blockedConstructSub?.cancel();
super.dispose();
}
void _onBlockConstruct(AnalyticsStreamUpdate update) {
final blocked = update.blockedConstruct;
if (blocked == null) return;
vocab?.removeWhere((e) => e.id == blocked);
if (widget.view == ConstructTypeEnum.vocab && widget.construct == null) {
setState(() {});
}
}
Future<void> _setVocab() async {
try {
final analyticsService = Matrix.of(context).analyticsDataService;
final data = await analyticsService
.getAggregatedConstructs(ConstructTypeEnum.vocab);
vocab = data.values.toList();
vocab!.sort(
(a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase()),
);
} finally {
if (mounted) setState(() {});
}
}
Future<void> _setMorphs() async {
try {
final resp = await MorphsRepo.get();

View file

@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/app_config.dart';
@ -104,6 +103,7 @@ class MorphFeatureBox extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final analyticsService = Matrix.of(context).analyticsDataService;
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
@ -142,40 +142,27 @@ class MorphFeatureBox extends StatelessWidget {
alignment: WrapAlignment.center,
spacing: 16.0,
runSpacing: 16.0,
children: allTags
.map(
(morphTag) {
final id = ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: morphFeature,
);
children: allTags.map(
(morphTag) {
final id = ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: morphFeature,
);
final analytics = MatrixState.pangeaController
.getAnalytics.constructListModel
.getConstructUses(id) ??
ConstructUses(
lemma: morphTag,
constructType: ConstructTypeEnum.morph,
category: morphFeature,
uses: [],
);
return MorphTagChip(
morphFeature: morphFeature,
morphTag: morphTag,
constructAnalytics: analytics,
onTap: () => context.go(
"/rooms/analytics/${id.type.string}/${Uri.encodeComponent(jsonEncode(id.toJson()))}",
),
);
},
)
.sortedBy<num>(
(chip) => chip.constructAnalytics.points,
)
.reversed
.toList(),
return FutureBuilder(
future: analyticsService.getConstructUse(id),
builder: (context, snapshot) => MorphTagChip(
morphFeature: morphFeature,
morphTag: morphTag,
constructAnalytics: snapshot.data,
onTap: () => context.go(
"/rooms/analytics/${id.type.string}/${Uri.encodeComponent(jsonEncode(id.toJson()))}",
),
),
);
},
).toList(),
),
),
],
@ -189,7 +176,7 @@ class MorphFeatureBox extends StatelessWidget {
class MorphTagChip extends StatelessWidget {
final String morphFeature;
final String morphTag;
final ConstructUses constructAnalytics;
final ConstructUses? constructAnalytics;
final VoidCallback? onTap;
const MorphTagChip({
@ -206,8 +193,9 @@ class MorphTagChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final unlocked = constructAnalytics.points > 0 ||
Matrix.of(context).client.userID == Environment.supportUserId;
final unlocked =
constructAnalytics != null && constructAnalytics!.points > 0 ||
Matrix.of(context).client.userID == Environment.supportUserId;
return Material(
type: MaterialType.transparency,
@ -224,7 +212,8 @@ class MorphTagChip extends StatelessWidget {
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
constructAnalytics.lemmaCategory.color(context),
constructAnalytics?.lemmaCategory.color(context) ??
ConstructLevelEnum.seeds.color(context),
Colors.transparent,
],
)

View file

@ -1,62 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
class ConstructUsesXPTile extends StatelessWidget {
final ConstructUses constructUses;
const ConstructUsesXPTile(
this.constructUses, {
super.key,
});
@override
Widget build(BuildContext context) {
final ProgressIndicatorEnum indicator =
constructUses.constructType == ConstructTypeEnum.morph
? ProgressIndicatorEnum.morphsUsed
: ProgressIndicatorEnum.wordsUsed;
return Tooltip(
message:
"${constructUses.points} / ${constructUses.constructType.maxXPPerLemma}",
child: ListTile(
onTap: () {},
title: Text(
constructUses.constructType == ConstructTypeEnum.morph
? getGrammarCopy(
category: constructUses.category,
lemma: constructUses.lemma,
context: context,
) ??
constructUses.lemma
: constructUses.lemma,
),
subtitle: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: constructUses.points /
constructUses.constructType.maxXPPerLemma,
minHeight: 20,
borderRadius: const BorderRadius.all(
Radius.circular(AppConfig.borderRadius),
),
color: indicator.color(context),
),
),
const SizedBox(width: 12),
Text("${constructUses.points}xp"),
],
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
),
);
}
}

View file

@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart';
import 'package:fluffychat/pangea/morphs/morph_feature_display.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_tag_display.dart';
import 'package:fluffychat/widgets/matrix.dart';
class MorphDetailsView extends StatelessWidget {
final ConstructIdentifier constructId;
@ -23,40 +24,49 @@ class MorphDetailsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final construct = constructId.constructUses;
final Color textColor = Theme.of(context).brightness != Brightness.light
? construct.lemmaCategory.color(context)
: construct.lemmaCategory.darkColor(context);
return FutureBuilder(
future:
Matrix.of(context).analyticsDataService.getConstructUse(constructId),
builder: (context, snapshot) {
final construct = snapshot.data;
final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds;
final Color textColor = Theme.of(context).brightness != Brightness.light
? level.color(context)
: level.darkColor(context);
return SingleChildScrollView(
child: Column(
spacing: 16.0,
children: [
MorphTagDisplay(
morphFeature: _morphFeature,
morphTag: _morphTag,
textColor: textColor,
return SingleChildScrollView(
child: Column(
spacing: 16.0,
children: [
MorphTagDisplay(
morphFeature: _morphFeature,
morphTag: _morphTag,
textColor: textColor,
),
MorphFeatureDisplay(morphFeature: _morphFeature),
MorphMeaningWidget(
feature: _morphFeature,
tag: _morphTag,
style: Theme.of(context).textTheme.bodyLarge,
),
const Divider(),
if (construct != null) ...[
ConstructXpWidget(
icon: construct.lemmaCategory.icon(30.0),
level: construct.lemmaCategory,
points: construct.points,
),
Padding(
padding: const EdgeInsets.all(20.0),
child: AnalyticsDetailsUsageContent(
construct: construct,
),
),
],
],
),
MorphFeatureDisplay(morphFeature: _morphFeature),
MorphMeaningWidget(
feature: _morphFeature,
tag: _morphTag,
style: Theme.of(context).textTheme.bodyLarge,
),
const Divider(),
ConstructXpWidget(
icon: construct.lemmaCategory.icon(30.0),
level: construct.lemmaCategory,
points: construct.points,
),
Padding(
padding: const EdgeInsets.all(20.0),
child: AnalyticsDetailsUsageContent(
construct: construct,
),
),
],
),
);
},
);
}
}

View file

@ -13,7 +13,7 @@ import 'package:fluffychat/pangea/toolbar/word_card/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Displays information about selected lemma, and its usage
class VocabDetailsView extends StatefulWidget {
class VocabDetailsView extends StatelessWidget {
final ConstructIdentifier constructId;
const VocabDetailsView({
@ -21,100 +21,89 @@ class VocabDetailsView extends StatefulWidget {
required this.constructId,
});
@override
State<VocabDetailsView> createState() => VocabDetailsViewState();
}
class VocabDetailsViewState extends State<VocabDetailsView> {
ConstructIdentifier get constructId => widget.constructId;
final ValueNotifier<String?> _emojiNotifier = ValueNotifier<String?>(null);
@override
void initState() {
super.initState();
_emojiNotifier.value = constructId.userLemmaInfo.emojis?.firstOrNull;
}
@override
void didUpdateWidget(covariant VocabDetailsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId) {
_emojiNotifier.value = constructId.userLemmaInfo.emojis?.firstOrNull;
}
}
@override
void dispose() {
_emojiNotifier.dispose();
super.dispose();
}
List<String> get forms =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUsesByLemma(constructId.lemma)
.map((e) => e.uses)
.expand((element) => element)
.map((e) => e.form?.toLowerCase())
.toSet()
.whereType<String>()
.toList();
@override
Widget build(BuildContext context) {
final construct = constructId.constructUses;
final Color textColor = (Theme.of(context).brightness != Brightness.light
? construct.lemmaCategory.color(context)
: construct.lemmaCategory.darkColor(context));
final analyticsService = Matrix.of(context).analyticsDataService;
return FutureBuilder(
future: analyticsService.getConstructUse(constructId),
builder: (context, snapshot) {
final construct = snapshot.data;
final level = construct?.lemmaCategory ?? ConstructLevelEnum.seeds;
return SingleChildScrollView(
child: Column(
spacing: 16.0,
children: [
WordZoomWidget(
token: PangeaTokenText.fromString(constructId.lemma),
langCode: MatrixState.pangeaController.userController.userL2Code!,
construct: constructId,
setEmoji: (emoji) => _emojiNotifier.value = emoji,
),
Column(
final Color textColor =
(Theme.of(context).brightness != Brightness.light
? level.color(context)
: level.darkColor(context));
final forms = construct?.uses
.map((e) => e.form)
.whereType<String>()
.toSet()
.toList() ??
[];
return SingleChildScrollView(
child: Column(
spacing: 16.0,
children: [
Padding(
padding: const EdgeInsets.all(20.0),
child: ConstructXpWidget(
icon: ValueListenableBuilder(
valueListenable: _emojiNotifier,
builder: (context, emoji, __) => Text(
emoji ?? "-",
style: const TextStyle(fontSize: 24.0),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
WordZoomWidget(
token: PangeaTokenText.fromString(constructId.lemma),
langCode:
MatrixState.pangeaController.userController.userL2Code!,
construct: constructId,
),
level: construct.lemmaCategory,
points: construct.points,
),
],
),
Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
if (construct != null)
Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: _VocabForms(
lemma: constructId.lemma,
forms: forms,
textColor: textColor,
Padding(
padding: const EdgeInsets.all(20.0),
child: ConstructXpWidget(
icon: StreamBuilder(
key: ValueKey(constructId.string),
stream: analyticsService.updateDispatcher
.lemmaUpdateStream(constructId),
builder: (context, update) {
final emoji = update.data?.emojis?.firstOrNull ??
constructId.userSetEmoji;
return Text(
emoji ?? "-",
style: const TextStyle(fontSize: 24.0),
);
},
),
level: construct.lemmaCategory,
points: construct.points,
),
),
AnalyticsDetailsUsageContent(
construct: construct,
Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
Align(
alignment: Alignment.centerLeft,
child: _VocabForms(
lemma: constructId.lemma,
forms: forms,
textColor: textColor,
),
),
AnalyticsDetailsUsageContent(
construct: construct,
),
],
),
),
],
),
),
],
),
],
),
);
},
);
}
}

View file

@ -3,22 +3,22 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/common/widgets/shrinkable_text.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabAnalyticsListTile extends StatefulWidget {
const VocabAnalyticsListTile({
super.key,
required this.emoji,
required this.constructId,
this.level = ConstructLevelEnum.seeds,
required this.textColor,
required this.icon,
this.onTap,
});
final String? emoji;
final void Function()? onTap;
final ConstructIdentifier constructId;
final ConstructLevelEnum level;
final Color textColor;
final Widget icon;
@override
VocabAnalyticsListTileState createState() => VocabAnalyticsListTileState();
@ -32,6 +32,7 @@ class VocabAnalyticsListTileState extends State<VocabAnalyticsListTile> {
@override
Widget build(BuildContext context) {
final analyticsService = Matrix.of(context).analyticsDataService;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
@ -53,10 +54,26 @@ class VocabAnalyticsListTileState extends State<VocabAnalyticsListTile> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
alignment: Alignment.center,
height: (maxWidth - padding * 2) * 0.6,
child: widget.icon,
StreamBuilder(
stream: analyticsService.updateDispatcher
.lemmaUpdateStream(widget.constructId),
builder: (context, snapshot) {
final emoji = snapshot.data?.emojis?.firstOrNull ??
widget.constructId.userSetEmoji;
return Container(
alignment: Alignment.center,
height: (maxWidth - padding * 2) * 0.6,
child: emoji != null
? Text(
emoji,
style: const TextStyle(
fontSize: 22,
),
)
: widget.level.icon(36.0),
);
},
),
Container(
alignment: Alignment.topCenter,

View file

@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/themes.dart';
@ -29,13 +28,8 @@ class VocabAnalyticsListView extends StatelessWidget {
required this.controller,
});
List<ConstructUses> get _vocab => MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.sorted((a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase()));
List<ConstructUses> get _filteredVocab => _vocab
.where(
List<ConstructUses>? get _filteredVocab => controller.vocab
?.where(
(use) =>
use.lemma.isNotEmpty &&
(controller.selectedConstructLevel == null
@ -51,11 +45,14 @@ class VocabAnalyticsListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final vocab = controller.vocab;
final List<Widget> filters = ConstructLevelEnum.values.reversed
.map((constructLevelCategory) {
final int count = _vocab
.where((e) => e.lemmaCategory == constructLevelCategory)
.length;
final int count = vocab
?.where((e) => e.lemmaCategory == constructLevelCategory)
.length ??
0;
return InkWell(
onTap: () =>
controller.setSelectedConstructLevel(constructLevelCategory),
@ -152,60 +149,60 @@ class VocabAnalyticsListView extends StatelessWidget {
),
// Grid of vocab tiles
_filteredVocab.isEmpty
? SliverToBoxAdapter(
child: controller.selectedConstructLevel != null
? Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
L10n.of(context).vocabLevelsDesc,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
)
: const SizedBox.shrink(),
)
: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisExtent: 100.0,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
if (vocab == null)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: CircularProgressIndicator.adaptive(),
),
)
else
vocab.isEmpty
? SliverToBoxAdapter(
child: controller.selectedConstructLevel != null
? Padding(
padding: const EdgeInsets.all(24.0),
child: Text(
L10n.of(context).vocabLevelsDesc,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
)
: const SizedBox.shrink(),
)
: SliverGrid(
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 100.0,
mainAxisExtent: 100.0,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final vocabItem = _filteredVocab![index];
return VocabAnalyticsListTile(
onTap: () {
TtsController.tryToSpeak(
vocabItem.id.lemma,
langCode: MatrixState.pangeaController
.userController.userL2Code!,
);
context.go(
"/rooms/analytics/${vocabItem.id.type.string}/${Uri.encodeComponent(jsonEncode(vocabItem.id.toJson()))}",
);
},
constructId: vocabItem.id,
textColor: Theme.of(context).brightness ==
Brightness.light
? vocabItem.lemmaCategory.darkColor(context)
: vocabItem.lemmaCategory.color(context),
level: vocabItem.lemmaCategory,
);
},
childCount: _filteredVocab!.length,
),
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final vocabItem = _filteredVocab[index];
return VocabAnalyticsListTile(
onTap: () {
TtsController.tryToSpeak(
vocabItem.id.lemma,
langCode: MatrixState.pangeaController
.userController.userL2Code!,
);
context.go(
"/rooms/analytics/${vocabItem.id.type.string}/${Uri.encodeComponent(jsonEncode(vocabItem.id.toJson()))}",
);
},
constructId: vocabItem.id,
textColor:
Theme.of(context).brightness == Brightness.light
? vocabItem.lemmaCategory.darkColor(context)
: vocabItem.lemmaCategory.color(context),
emoji: vocabItem.id.userSetEmoji.firstOrNull,
icon: vocabItem.id.userSetEmoji.isNotEmpty
? Text(
vocabItem.id.userSetEmoji.first,
style: const TextStyle(
fontSize: 22,
),
)
: vocabItem.lemmaCategory.icon(36.0),
);
},
childCount: _filteredVocab.length,
),
),
],
),
),

View file

@ -1,69 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabDetailsEmojiSelector extends StatefulWidget {
final ConstructIdentifier constructId;
const VocabDetailsEmojiSelector(
this.constructId, {
super.key,
});
@override
State<VocabDetailsEmojiSelector> createState() =>
VocabDetailsEmojiSelectorState();
}
class VocabDetailsEmojiSelectorState extends State<VocabDetailsEmojiSelector>
with LemmaEmojiSetter {
String? selectedEmoji;
@override
void initState() {
super.initState();
_setInitialEmoji();
}
@override
void didUpdateWidget(covariant VocabDetailsEmojiSelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId) {
_setInitialEmoji();
}
}
void _setInitialEmoji() {
setState(
() {
selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull;
},
);
}
Future<void> _setEmoji(String emoji) async {
setState(() => selectedEmoji = emoji);
await setLemmaEmoji(
widget.constructId,
emoji,
"emoji-choice-item-$emoji-${widget.constructId.lemma}",
);
showLemmaEmojiSnackbar(context, widget.constructId, emoji);
}
@override
Widget build(BuildContext context) {
return LemmaHighlightEmojiRow(
cId: widget.constructId,
langCode: MatrixState.pangeaController.userController.userL2Code!,
emoji: selectedEmoji,
onEmojiSelected: _setEmoji,
messageInfo: const {},
);
}
}

View file

@ -147,8 +147,11 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
}
Future<List<AnalyticsSummaryModel>> _getVocabAnalytics() async {
final uses = MatrixState.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab);
final analyticsService = Matrix.of(context).analyticsDataService;
final aggregatedVocab =
await analyticsService.getAggregatedConstructs(ConstructTypeEnum.vocab);
final uses = aggregatedVocab.values.toList();
final Map<String, List<ConstructUses>> lemmasToUses = {};
for (final use in uses) {
lemmasToUses[use.lemma] ??= [];
@ -194,8 +197,7 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
}
Future<List<AnalyticsSummaryModel>> _getMorphAnalytics() async {
final constructListModel =
MatrixState.pangeaController.getAnalytics.constructListModel;
final analyticsService = Matrix.of(context).analyticsDataService;
final morphs = await MorphsRepo.get();
final List<AnalyticsSummaryModel> summaries = [];
@ -212,8 +214,7 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
category: feature.feature,
);
final uses = constructListModel.getConstructUses(id);
if (uses == null) continue;
final uses = await analyticsService.getConstructUse(id);
final xp = uses.points;
final exampleMessages = await _getExampleMessages([uses]);

View file

@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.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';
class SpaceAnalyticsSummaryModel {
String username;
@ -87,127 +91,223 @@ class SpaceAnalyticsSummaryModel {
);
}
static SpaceAnalyticsSummaryModel fromConstructListModel(
String userID,
ConstructListModel? model,
static SpaceAnalyticsSummaryModel fromEvents(
String username,
List<ConstructAnalyticsEvent> events,
Set<ConstructIdentifier> blockedConstructs,
int numCompletedActivities,
String Function(ConstructUses) getCopy,
BuildContext context,
) {
final vocabLemmas = model != null
? LemmasToUsesWrapper(
model.lemmasToUses(type: ConstructTypeEnum.vocab),
)
: null;
final morphLemmas = model != null
? LemmasToUsesWrapper(
model.lemmasToUses(type: ConstructTypeEnum.morph),
)
: null;
int totalXP = 0;
int numWordsTyped = 0;
int numChoicesCorrect = 0;
int numChoicesIncorrect = 0;
final List<String> correctOriginalUseLemmas = [];
final List<String> correctSystemUseLemmas = [];
final List<String> incorrectOriginalUseLemmas = [];
final List<String> incorrectSystemUseLemmas = [];
final Set<String> sentEventIds = {};
final List<OneConstructUse> allUses = [];
if (morphLemmas != null) {
final originalWrittenUses = morphLemmas.lemmasByPercent(
filter: (use) =>
use.useType == ConstructUseTypeEnum.wa ||
use.useType == ConstructUseTypeEnum.ga ||
use.useType == ConstructUseTypeEnum.ta,
percent: 0.8,
context: context,
);
final Map<ConstructIdentifier, List<OneConstructUse>> aggregatedVocab = {};
final Map<ConstructIdentifier, List<OneConstructUse>> aggregatedMorph = {};
correctOriginalUseLemmas.addAll(originalWrittenUses.over);
incorrectOriginalUseLemmas.addAll(originalWrittenUses.under);
final ConstructMergeTable mergeTable = ConstructMergeTable();
final systemGeneratedUses = morphLemmas.lemmasByPercent(
filter: (use) =>
use.useType != ConstructUseTypeEnum.wa &&
use.useType != ConstructUseTypeEnum.ga &&
use.useType != ConstructUseTypeEnum.ta &&
use.useType != ConstructUseTypeEnum.unk &&
use.xp != 0,
percent: 0.8,
context: context,
);
for (final e in events) {
mergeTable.addConstructsByUses(e.content.uses, blockedConstructs);
correctSystemUseLemmas.addAll(systemGeneratedUses.over);
incorrectSystemUseLemmas.addAll(systemGeneratedUses.under);
}
for (final use in e.content.uses) {
totalXP += use.xp;
allUses.add(use);
final vocabLemmasCorrect = vocabLemmas?.lemmasByCorrectUse();
int? numWordsTyped;
int? numChoicesCorrect;
int? numChoicesIncorrect;
if (model != null) {
numWordsTyped = 0;
numChoicesCorrect = 0;
numChoicesIncorrect = 0;
for (final use in model.uses) {
if (use.useType.summaryEnumType ==
SpaceAnalyticsSummaryEnum.numWordsTyped) {
numWordsTyped = numWordsTyped! + 1;
numWordsTyped = numWordsTyped + 1;
} else if (use.useType.summaryEnumType ==
SpaceAnalyticsSummaryEnum.numChoicesCorrect) {
numChoicesCorrect = numChoicesCorrect! + 1;
numChoicesCorrect = numChoicesCorrect + 1;
} else if (use.useType.summaryEnumType ==
SpaceAnalyticsSummaryEnum.numChoicesIncorrect) {
numChoicesIncorrect = numChoicesIncorrect! + 1;
numChoicesIncorrect = numChoicesIncorrect + 1;
}
if (use.useType.sentByUser && use.metadata.eventId != null) {
sentEventIds.add(use.metadata.eventId!);
}
final id = use.identifier;
final existing = id.type == ConstructTypeEnum.vocab
? aggregatedVocab[id]
: aggregatedMorph[id];
if (existing != null) {
existing.add(use);
} else {
id.type == ConstructTypeEnum.vocab
? aggregatedVocab[id] = [use]
: aggregatedMorph[id] = [use];
}
}
}
final numMessageSent = model?.uses
.where((use) => use.useType.sentByUser)
.map((use) => use.metadata.eventId)
.toSet()
.length;
final Map<ConstructIdentifier, ConstructUses> aggregatedVocabUses = {};
for (final entry in aggregatedVocab.entries) {
aggregatedVocabUses[entry.key] = ConstructUses(
lemma: entry.value.first.lemma,
constructType: entry.value.first.constructType,
category: entry.value.first.category,
uses: entry.value,
);
}
final Map<ConstructIdentifier, ConstructUses> aggregatedMorphUses = {};
for (final entry in aggregatedMorph.entries) {
aggregatedMorphUses[entry.key] = ConstructUses(
lemma: entry.value.first.lemma,
constructType: entry.value.first.constructType,
category: entry.value.first.category,
uses: entry.value,
);
}
final cleanedVocab = <ConstructIdentifier, ConstructUses>{};
for (final entry in aggregatedVocabUses.values) {
final canonical = mergeTable.resolve(entry.id);
final existing = cleanedVocab[canonical];
if (existing != null) {
existing.merge(entry);
} else {
cleanedVocab[canonical] = entry;
}
}
final cleanedMorph = <ConstructIdentifier, ConstructUses>{};
for (final entry in aggregatedMorphUses.values) {
final canonical = mergeTable.resolve(entry.id);
final existing = cleanedMorph[canonical];
if (existing != null) {
existing.merge(entry);
} else {
cleanedMorph[canonical] = entry;
}
}
final level = DerivedAnalyticsDataModel.calculateLevelWithXp(totalXP);
final uniqueVocabCount = cleanedVocab.length;
final uniqueMorphCount = cleanedMorph.length;
int vocabUsedCorrectly = 0;
int vocabUsedIncorrectly = 0;
int vocabSmallXP = 0;
int vocabMediumXP = 0;
int vocabLargeXP = 0;
for (final entry in cleanedVocab.values) {
final xp = entry.points;
if (xp >= 0 && xp <= 29) {
vocabSmallXP += 1;
} else if (xp >= 30 && xp < 200) {
vocabMediumXP += 1;
} else if (xp >= 200) {
vocabLargeXP += 1;
}
if (entry.hasCorrectUse) {
vocabUsedCorrectly += 1;
} else {
vocabUsedIncorrectly += 1;
}
}
final originalUseTypes = {
ConstructUseTypeEnum.wa,
ConstructUseTypeEnum.ga,
ConstructUseTypeEnum.ta,
};
final List<String> morphConstructs = [];
final List<String> morphSmallXP = [];
final List<String> morphMediumXP = [];
final List<String> morphLargeXP = [];
final List<String> morphHugeXP = [];
final List<String> morphCorrectOriginal = [];
final List<String> morphIncorrectOriginal = [];
final List<String> morphCorrectSystem = [];
final List<String> morphIncorrectSystem = [];
for (final entry in cleanedMorph.values) {
morphConstructs.add(entry.lemma);
final xp = entry.points;
if (xp >= 0 && xp <= 50) {
morphSmallXP.add(entry.lemma);
} else if (xp >= 51 && xp <= 200) {
morphMediumXP.add(entry.lemma);
} else if (xp >= 201 && xp <= 500) {
morphLargeXP.add(entry.lemma);
} else if (xp >= 501) {
morphHugeXP.add(entry.lemma);
}
final originalUsesCorrect = [];
final originalUsesIncorrect = [];
final systemUsesCorrect = [];
final systemUsesIncorrect = [];
for (final use in entry.uses) {
if (originalUseTypes.contains(use.useType)) {
use.xp > 0
? originalUsesCorrect.add(use)
: originalUsesIncorrect.add(use);
} else {
use.xp > 0
? systemUsesCorrect.add(use)
: systemUsesIncorrect.add(use);
}
}
// if >= 80% correct original uses
if (originalUsesCorrect.length + originalUsesIncorrect.length > 0) {
final percentCorrect = originalUsesCorrect.length /
(originalUsesCorrect.length + originalUsesIncorrect.length);
if (percentCorrect >= 0.8) {
morphCorrectOriginal.add(entry.lemma);
} else {
morphIncorrectOriginal.add(entry.lemma);
}
if (systemUsesCorrect.length + systemUsesIncorrect.length > 0) {
final percentCorrectSystem = systemUsesCorrect.length /
(systemUsesCorrect.length + systemUsesIncorrect.length);
if (percentCorrectSystem >= 0.8) {
morphCorrectSystem.add(entry.lemma);
} else {
morphIncorrectSystem.add(entry.lemma);
}
}
}
}
return SpaceAnalyticsSummaryModel(
username: userID,
dataAvailable: model != null,
level: model?.level,
totalXP: model?.totalXP,
numLemmas: model?.numConstructs(ConstructTypeEnum.vocab),
numLemmasUsedCorrectly: vocabLemmasCorrect?.over.length,
numLemmasUsedIncorrectly: vocabLemmasCorrect?.under.length,
numLemmasSmallXP:
vocabLemmas?.thresholdedLemmas(start: 0, end: 30).length,
numLemmasMediumXP:
vocabLemmas?.thresholdedLemmas(start: 31, end: 200).length,
numLemmasLargeXP: vocabLemmas?.thresholdedLemmas(start: 201).length,
numMorphConstructs: model?.numConstructs(ConstructTypeEnum.morph),
listMorphConstructs: morphLemmas?.lemmasToUses.entries
.map((entry) => getCopy(entry.value.first))
.toList(),
listMorphConstructsUsedCorrectlyOriginal: correctOriginalUseLemmas,
listMorphConstructsUsedIncorrectlyOriginal: incorrectOriginalUseLemmas,
listMorphConstructsUsedCorrectlySystem: correctSystemUseLemmas,
listMorphConstructsUsedIncorrectlySystem: incorrectSystemUseLemmas,
listMorphSmallXP: morphLemmas?.thresholdedLemmas(
start: 0,
end: 50,
getCopy: getCopy,
),
listMorphMediumXP: morphLemmas?.thresholdedLemmas(
start: 51,
end: 200,
getCopy: getCopy,
),
listMorphLargeXP: morphLemmas?.thresholdedLemmas(
start: 201,
end: 500,
getCopy: getCopy,
),
listMorphHugeXP: morphLemmas?.thresholdedLemmas(
start: 501,
getCopy: getCopy,
),
numMessagesSent: numMessageSent,
username: username,
dataAvailable: true,
level: level,
totalXP: totalXP,
numLemmas: uniqueVocabCount,
numLemmasUsedCorrectly: vocabUsedCorrectly,
numLemmasUsedIncorrectly: vocabUsedIncorrectly,
numLemmasSmallXP: vocabSmallXP,
numLemmasMediumXP: vocabMediumXP,
numLemmasLargeXP: vocabLargeXP,
numMorphConstructs: uniqueMorphCount,
listMorphConstructs: morphConstructs,
listMorphSmallXP: morphSmallXP,
listMorphMediumXP: morphMediumXP,
listMorphLargeXP: morphLargeXP,
listMorphHugeXP: morphHugeXP,
listMorphConstructsUsedCorrectlyOriginal: morphCorrectOriginal,
listMorphConstructsUsedIncorrectlyOriginal: morphIncorrectOriginal,
listMorphConstructsUsedCorrectlySystem: morphCorrectSystem,
listMorphConstructsUsedIncorrectlySystem: morphIncorrectSystem,
numMessagesSent: sentEventIds.length,
numWordsTyped: numWordsTyped,
numChoicesCorrect: numChoicesCorrect,
numChoicesIncorrect: numChoicesIncorrect,
@ -215,6 +315,134 @@ class SpaceAnalyticsSummaryModel {
);
}
// static SpaceAnalyticsSummaryModel fromConstructListModel(
// String userID,
// ConstructListModel? model,
// int numCompletedActivities,
// String Function(ConstructUses) getCopy,
// BuildContext context,
// ) {
// final vocabLemmas = model != null
// ? LemmasToUsesWrapper(
// model.lemmasToUses(type: ConstructTypeEnum.vocab),
// )
// : null;
// final morphLemmas = model != null
// ? LemmasToUsesWrapper(
// model.lemmasToUses(type: ConstructTypeEnum.morph),
// )
// : null;
// final List<String> correctOriginalUseLemmas = [];
// final List<String> correctSystemUseLemmas = [];
// final List<String> incorrectOriginalUseLemmas = [];
// final List<String> incorrectSystemUseLemmas = [];
// if (morphLemmas != null) {
// final originalWrittenUses = morphLemmas.lemmasByPercent(
// filter: (use) =>
// use.useType == ConstructUseTypeEnum.wa ||
// use.useType == ConstructUseTypeEnum.ga ||
// use.useType == ConstructUseTypeEnum.ta,
// percent: 0.8,
// context: context,
// );
// correctOriginalUseLemmas.addAll(originalWrittenUses.over);
// incorrectOriginalUseLemmas.addAll(originalWrittenUses.under);
// final systemGeneratedUses = morphLemmas.lemmasByPercent(
// filter: (use) =>
// use.useType != ConstructUseTypeEnum.wa &&
// use.useType != ConstructUseTypeEnum.ga &&
// use.useType != ConstructUseTypeEnum.ta &&
// use.useType != ConstructUseTypeEnum.unk &&
// use.xp != 0,
// percent: 0.8,
// context: context,
// );
// correctSystemUseLemmas.addAll(systemGeneratedUses.over);
// incorrectSystemUseLemmas.addAll(systemGeneratedUses.under);
// }
// final vocabLemmasCorrect = vocabLemmas?.lemmasByCorrectUse();
// int? numWordsTyped;
// int? numChoicesCorrect;
// int? numChoicesIncorrect;
// if (model != null) {
// numWordsTyped = 0;
// numChoicesCorrect = 0;
// numChoicesIncorrect = 0;
// for (final use in model.uses) {
// if (use.useType.summaryEnumType ==
// SpaceAnalyticsSummaryEnum.numWordsTyped) {
// numWordsTyped = numWordsTyped! + 1;
// } else if (use.useType.summaryEnumType ==
// SpaceAnalyticsSummaryEnum.numChoicesCorrect) {
// numChoicesCorrect = numChoicesCorrect! + 1;
// } else if (use.useType.summaryEnumType ==
// SpaceAnalyticsSummaryEnum.numChoicesIncorrect) {
// numChoicesIncorrect = numChoicesIncorrect! + 1;
// }
// }
// }
// final numMessageSent = model?.uses
// .where((use) => use.useType.sentByUser)
// .map((use) => use.metadata.eventId)
// .toSet()
// .length;
// return SpaceAnalyticsSummaryModel(
// username: userID,
// dataAvailable: model != null,
// level: model?.level,
// totalXP: model?.totalXP,
// numLemmas: model?.numConstructs(ConstructTypeEnum.vocab),
// numLemmasUsedCorrectly: vocabLemmasCorrect?.over.length,
// numLemmasUsedIncorrectly: vocabLemmasCorrect?.under.length,
// numLemmasSmallXP:
// vocabLemmas?.thresholdedLemmas(start: 0, end: 30).length,
// numLemmasMediumXP:
// vocabLemmas?.thresholdedLemmas(start: 31, end: 200).length,
// numLemmasLargeXP: vocabLemmas?.thresholdedLemmas(start: 201).length,
// numMorphConstructs: model?.numConstructs(ConstructTypeEnum.morph),
// listMorphConstructs: morphLemmas?.lemmasToUses.entries
// .map((entry) => getCopy(entry.value.first))
// .toList(),
// listMorphConstructsUsedCorrectlyOriginal: correctOriginalUseLemmas,
// listMorphConstructsUsedIncorrectlyOriginal: incorrectOriginalUseLemmas,
// listMorphConstructsUsedCorrectlySystem: correctSystemUseLemmas,
// listMorphConstructsUsedIncorrectlySystem: incorrectSystemUseLemmas,
// listMorphSmallXP: morphLemmas?.thresholdedLemmas(
// start: 0,
// end: 50,
// getCopy: getCopy,
// ),
// listMorphMediumXP: morphLemmas?.thresholdedLemmas(
// start: 51,
// end: 200,
// getCopy: getCopy,
// ),
// listMorphLargeXP: morphLemmas?.thresholdedLemmas(
// start: 201,
// end: 500,
// getCopy: getCopy,
// ),
// listMorphHugeXP: morphLemmas?.thresholdedLemmas(
// start: 501,
// getCopy: getCopy,
// ),
// numMessagesSent: numMessageSent,
// numWordsTyped: numWordsTyped,
// numChoicesCorrect: numChoicesCorrect,
// numChoicesIncorrect: numChoicesIncorrect,
// numCompletedActivities: numCompletedActivities,
// );
// }
dynamic getValue(SpaceAnalyticsSummaryEnum key, BuildContext context) {
switch (key) {
case SpaceAnalyticsSummaryEnum.username:

View file

@ -1,370 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
/// A wrapper around a list of [OneConstructUse]s, used to simplify
/// the process of filtering / sorting / displaying the events.
class ConstructListModel {
final List<OneConstructUse> _uses = [];
List<OneConstructUse> get uses => _uses;
List<OneConstructUse> get truncatedUses => _uses.take(100).toList();
/// A map of ConstructIdentifiers to ConstructUses, each of which contains a lemma
/// key = lemma + constructType.string, value = ConstructUses
final Map<String, ConstructUses> _constructMap = {};
/// Storing this to avoid re-running the sort operation each time this needs to
/// be accessed. It contains the same information as _constructMap, but sorted.
List<ConstructUses> _constructList = [];
/// [D] is the "compression factor". It determines how quickly
/// or slowly the level grows relative to XP
final double D = Environment.isStagingEnvironment ? 500 : 1500;
/// Analytics data consumed by widgets. Updated each time new analytics come in.
int prevXP = 0;
int totalXP = 0;
int level = 0;
ConstructListModel({
required List<OneConstructUse> uses,
int offset = 0,
}) {
updateConstructs(uses, offset);
}
/// Given a list of new construct uses, update the map of construct
/// IDs to ConstructUses and re-sort the list of ConstructUses
void updateConstructs(List<OneConstructUse> newUses, int offset) {
try {
_updateUsesList(newUses);
_updateConstructMap(newUses);
_updateConstructList();
_updateMetrics(offset);
} catch (err, s) {
ErrorHandler.logError(
e: "Failed to update analytics: $err",
s: s,
data: {
"newUses": newUses.map((e) => e.toJson()),
},
);
}
}
int _sortConstructs(ConstructUses a, ConstructUses b) {
final comp = b.points.compareTo(a.points);
if (comp != 0) return comp;
return a.lemma.compareTo(b.lemma);
}
void _updateUsesList(List<OneConstructUse> newUses) {
newUses.sort((a, b) => b.timeStamp.compareTo(a.timeStamp));
_uses.insertAll(0, newUses);
}
/// A map of lemmas to ConstructUses, each of which contains a lemma
/// key = lemmma + constructType.string, value = ConstructUses
void _updateConstructMap(final List<OneConstructUse> newUses) {
for (final use in newUses) {
final currentUses = _constructMap[use.identifier.string] ??
ConstructUses(
uses: [],
constructType: use.constructType,
lemma: use.lemma,
category: use.category,
);
currentUses.uses.add(use);
currentUses.setLastUsed(use.timeStamp);
_constructMap[use.identifier.string] = currentUses;
}
final broadKeys = _constructMap.keys.where((key) => key.endsWith('other'));
final replacedKeys = [];
for (final broadKey in broadKeys) {
final specificKeyPrefix = broadKey.split("-").first;
final specificKey = _constructMap.keys.firstWhereOrNull(
(key) =>
key != broadKey &&
key.startsWith(specificKeyPrefix) &&
!key.endsWith('other'),
);
if (specificKey == null) continue;
final broadConstructEntry = _constructMap[broadKey];
final specificConstructEntry = _constructMap[specificKey];
specificConstructEntry!.uses.addAll(broadConstructEntry!.uses);
_constructMap[specificKey] = specificConstructEntry;
replacedKeys.add(broadKey);
}
for (final key in replacedKeys) {
_constructMap.remove(key);
}
}
/// A list of ConstructUses, each of which contains a lemma and
/// a list of uses, sorted by the number of uses
void _updateConstructList() {
// TODO check how expensive this is
_constructList = _constructMap.values.toList();
_constructList.sort(_sortConstructs);
}
void _updateMetrics(int offset) {
prevXP = totalXP;
totalXP = (_constructList.fold<int>(
0,
(total, construct) => total + construct.points,
)) +
offset;
if (totalXP < 0) {
totalXP = 0;
}
level = calculateLevelWithXp(totalXP);
}
void deleteConstruct(ConstructIdentifier constructId, int offset) {
_uses.removeWhere((use) => use.identifier == constructId);
_constructMap.removeWhere(
(key, value) => value.id == constructId,
);
updateConstructs([], offset);
}
List<ConstructUses> constructList({ConstructTypeEnum? type}) => _constructList
.where(
(constructUse) => type == null || constructUse.constructType == type,
)
.toList();
// TODO; make this non-nullable, returning empty if not found
ConstructUses? getConstructUses(ConstructIdentifier identifier) {
final partialKey = "${identifier.lemma}-${identifier.type.string}";
if (_constructMap.containsKey(identifier.string)) {
// try to get construct use entry with full ID key
return _constructMap[identifier.string];
} else if (identifier.category == "other") {
// if the category passed to this function is "other", return the first
// construct use entry that starts with the partial key
return _constructMap.entries
.firstWhereOrNull((entry) => entry.key.startsWith(partialKey))
?.value;
} else {
// if the category passed to this function is not "other", return the first
// construct use entry that starts with the partial key and ends with "other"
return _constructMap.entries
.firstWhereOrNull(
(entry) =>
entry.key.startsWith(partialKey) && entry.key.endsWith("other"),
)
?.value;
}
}
List<ConstructUses> getConstructUsesByLemma(String lemma) => _constructList
.where(
(constructUse) => constructUse.lemma == lemma,
)
.toList();
int numConstructs(ConstructTypeEnum type) => constructList(type: type).length;
int calculateLevelWithXp(int totalXP) {
final doubleScore = (1 + sqrt((1 + (8.0 * totalXP / D)) / 2.0));
if (!doubleScore.isNaN && doubleScore.isFinite) {
return doubleScore.floor();
} else {
ErrorHandler.logError(
e: "Calculated level in Nan or Infinity",
data: {
"totalXP": totalXP,
"prevXP": prevXP,
"level": doubleScore,
},
);
return 1;
}
}
int calculateXpWithLevel(int level) {
// If level <= 1, XP should be 0 or negative by this math.
// In practice, you might clamp it to 0:
if (level <= 1) {
return 0;
}
// Convert level to double for the math
final double lc = level.toDouble();
// XP from the inverse formula:
final double xpDouble = (D / 8.0) * (2.0 * pow(lc - 1.0, 2.0) - 1.0);
// Floor or clamp to ensure non-negative.
final int xp = xpDouble.floor();
return (xp < 0) ? 0 : xp;
}
/// Unique construct identifiers with XP >= [threshold]
/// Used on analytics update to determine newly 'unlocked' constructs
List<ConstructIdentifier> unlockedLemmas(
ConstructTypeEnum type, {
int threshold = 0,
}) {
final constructs = constructList(type: type);
final List<ConstructIdentifier> unlocked = [];
final constructsSet = constructList(type: type).map((e) => e.lemma).toSet();
for (final lemma in constructsSet) {
final matches = constructs.where((m) => m.lemma == lemma);
final totalPoints = matches.fold<int>(
0,
(total, match) => total + match.points,
);
if (totalPoints > threshold) {
unlocked.add(matches.first.id);
}
}
return unlocked;
}
// Not storing this for now to reduce memory load
// It's only used by downloads, so doesn't need to be accessible on the fly
Map<String, List<ConstructUses>> lemmasToUses({
ConstructTypeEnum? type,
}) {
final Map<String, List<ConstructUses>> lemmasToUses = {};
final constructs = constructList(type: type);
for (final ConstructUses use in constructs) {
final lemma = use.lemma;
lemmasToUses.putIfAbsent(lemma, () => []);
lemmasToUses[lemma]!.add(use);
}
return lemmasToUses;
}
}
class LemmasToUsesWrapper {
final Map<String, List<ConstructUses>> lemmasToUses;
LemmasToUsesWrapper(this.lemmasToUses);
Map<String, List<OneConstructUse>> lemmasToFilteredUses(
bool Function(OneConstructUse) filter,
) {
final Map<String, List<OneConstructUse>> lemmasToOneConstructUses = {};
for (final entry in lemmasToUses.entries) {
final lemma = entry.key;
final uses = entry.value;
lemmasToOneConstructUses[lemma] =
uses.expand((use) => use.uses).toList().where(filter).toList();
}
return lemmasToOneConstructUses;
}
LemmasOverUnderList lemmasByPercent({
required bool Function(OneConstructUse) filter,
required double percent,
required BuildContext context,
}) {
final List<String> correctUseLemmas = [];
final List<String> incorrectUseLemmas = [];
final uses = lemmasToFilteredUses(filter);
for (final entry in uses.entries) {
if (entry.value.isEmpty) continue;
final List<OneConstructUse> correctUses = [];
final List<OneConstructUse> incorrectUses = [];
final lemma = getGrammarCopy(
category: entry.value.first.category,
lemma: entry.key,
context: context,
) ??
entry.key;
final uses = entry.value.toList();
for (final use in uses) {
use.xp > 0 ? correctUses.add(use) : incorrectUses.add(use);
}
final totalUses = correctUses.length + incorrectUses.length;
final percent = totalUses == 0 ? 0 : correctUses.length / totalUses;
percent > 0.8
? correctUseLemmas.add(lemma)
: incorrectUseLemmas.add(lemma);
}
return LemmasOverUnderList(
over: correctUseLemmas,
under: incorrectUseLemmas,
);
}
/// Return an object containing two lists, one of lemmas with
/// any correct uses and one of lemmas no correct uses
LemmasOverUnderList lemmasByCorrectUse({
String Function(ConstructUses)? getCopy,
}) {
final List<String> correctLemmas = [];
final List<String> incorrectLemmas = [];
for (final entry in lemmasToUses.entries) {
final lemma = entry.key;
final constructUses = entry.value;
final copy = getCopy?.call(constructUses.first) ?? lemma;
if (constructUses.any((use) => use.hasCorrectUse)) {
correctLemmas.add(copy);
} else {
incorrectLemmas.add(copy);
}
}
return LemmasOverUnderList(over: correctLemmas, under: incorrectLemmas);
}
int totalXP(String lemma) {
final uses = lemmasToUses[lemma];
if (uses == null) return 0;
if (uses.length == 1) return uses.first.points;
return lemmasToUses[lemma]!.fold<int>(
0,
(total, use) => total + use.points,
);
}
List<String> thresholdedLemmas({
required int start,
int? end,
String Function(ConstructUses)? getCopy,
}) {
final filteredList = lemmasToUses.entries.where((entry) {
final xp = totalXP(entry.key);
return xp >= start && (end == null || xp <= end);
});
return filteredList
.map((entry) => getCopy?.call(entry.value.first) ?? entry.key)
.toList();
}
}
class LemmasOverUnderList {
final List<String> over;
final List<String> under;
LemmasOverUnderList({
required this.over,
required this.under,
});
}

View file

@ -1,6 +1,5 @@
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.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/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
@ -10,7 +9,7 @@ class ConstructUses {
final List<OneConstructUse> uses;
final ConstructTypeEnum constructType;
final String lemma;
final String? _category;
String? _category;
DateTime? _lastUsed;
ConstructUses({
@ -38,7 +37,9 @@ class ConstructUses {
}
void setLastUsed(DateTime time) {
_lastUsed = time;
if (_lastUsed == null || time.isAfter(_lastUsed!)) {
_lastUsed = time;
}
}
String get category {
@ -60,13 +61,33 @@ class ConstructUses {
'construct_id': id.toJson(),
'xp': points,
'last_used': lastUsed?.toIso8601String(),
/// NOTE - sent to server as just the useTypes
'uses': uses.map((e) => e.useType.string).toList(),
'uses': uses.map((e) => e.toJson()).toList(),
};
return json;
}
factory ConstructUses.fromJson(Map<String, dynamic> json) {
final constructId = ConstructIdentifier.fromJson(
Map<String, dynamic>.from(json['construct_id']),
);
List<dynamic> usesJson = [];
if (json['uses'] is List) {
usesJson = List<dynamic>.from(json['uses']);
}
final uses = usesJson
.map((e) => OneConstructUse.fromJson(Map<String, dynamic>.from(e)))
.toList();
return ConstructUses(
uses: uses,
constructType: constructId.type,
lemma: constructId.lemma,
category: constructId.category,
);
}
/// Get the lemma category, based on points
ConstructLevelEnum get lemmaCategory {
if (points < AnalyticsConstants.xpForGreens) {
@ -99,4 +120,36 @@ class ConstructUses {
return ConstructLevelEnum.flowers;
}
}
void merge(ConstructUses other) {
if (other.lemma.toLowerCase() != lemma.toLowerCase() ||
other.constructType != constructType) {
throw ArgumentError(
'Cannot merge ConstructUses with different lemmas or types',
);
}
uses.addAll(other.uses);
if (other.lastUsed != null) {
setLastUsed(other.lastUsed!);
}
if (category == 'other' && other.category != 'other') {
_category = other.category;
}
}
ConstructUses copyWith({
List<OneConstructUse>? uses,
ConstructTypeEnum? constructType,
String? lemma,
String? category,
}) {
return ConstructUses(
uses: uses ?? this.uses,
constructType: constructType ?? this.constructType,
lemma: lemma ?? this.lemma,
category: category ?? _category,
);
}
}

View file

@ -151,6 +151,28 @@ class OneConstructUse {
'xp': xp,
};
OneConstructUse copyWith({
String? lemma,
String? form,
String? category,
ConstructTypeEnum? constructType,
ConstructUseTypeEnum? useType,
String? id,
ConstructUseMetaData? metadata,
int? xp,
}) {
return OneConstructUse(
lemma: lemma ?? this.lemma,
form: form ?? this.form,
category: category ?? this.category,
constructType: constructType ?? this.constructType,
useType: useType ?? this.useType,
id: id ?? this.id,
metadata: metadata ?? this.metadata,
xp: xp ?? this.xp,
);
}
String get category {
if (_category.isEmpty) return "other";
return _category.toLowerCase();

View file

@ -1,650 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/common/constants/local.key.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController extends BaseController {
static final GetStorage analyticsBox = GetStorage("analytics_storage");
late PangeaController _pangeaController;
late PracticeSelectionRepo perMessage;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
StreamController<AnalyticsStreamUpdate> analyticsStream =
StreamController.broadcast();
StreamSubscription? _joinSpaceSubscription;
ConstructListModel constructListModel = ConstructListModel(uses: []);
Completer<void> initCompleter = Completer<void>();
bool _initializing = false;
GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
LanguageModel? get _l1 => _pangeaController.userController.userL1;
LanguageModel? get _l2 => _pangeaController.userController.userL2;
Client get _client => _pangeaController.matrixState.client;
// the minimum XP required for a given level
int get _minXPForLevel {
return constructListModel.calculateXpWithLevel(constructListModel.level);
}
// the minimum XP required for the next level
int get _minXPForNextLevel {
return constructListModel
.calculateXpWithLevel(constructListModel.level + 1);
}
int get minXPForNextLevel => _minXPForNextLevel;
// the progress within the current level as a percentage (0.0 to 1.0)
double get levelProgress {
final progress = (constructListModel.totalXP - _minXPForLevel) /
(_minXPForNextLevel - _minXPForLevel);
return progress >= 0 ? progress : 0;
}
Future<void> initialize() async {
if (_initializing || initCompleter.isCompleted) return;
_initializing = true;
try {
await GetStorage.init("analytics_storage");
await GetStorage.init("activity_analytics_storage");
_client.updateAnalyticsRoomJoinRules();
_client.addAnalyticsRoomsToSpaces();
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);
_pangeaController.putAnalytics.savedActivitiesNotifier
.addListener(_onActivityAnalyticsUpdate);
_pangeaController.putAnalytics.blockedConstructsNotifier
.addListener(_onBlockedConstructsUpdate);
// When a newly-joined space comes through in a sync
// update, add the analytics rooms to the space
_joinSpaceSubscription ??= _client.onSync.stream
.where(_client.isJoinSpaceSyncUpdate)
.listen((_) => _client.addAnalyticsRoomsToSpaces());
await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
await _getConstructs();
final offset =
_pangeaController.userController.analyticsProfile?.xpOffset ?? 0;
final allUses = [
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
];
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
final blockedLemmas = analyticsRoom?.analyticsSettings?.blockedConstructs;
if (blockedLemmas != null && blockedLemmas.isNotEmpty) {
allUses.removeWhere(
(use) => blockedLemmas.contains(use.identifier),
);
}
constructListModel.updateConstructs(
[
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
],
offset,
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {},
);
} finally {
_updateAnalyticsStream(AnalyticsStreamUpdate());
if (!initCompleter.isCompleted) initCompleter.complete();
_initializing = false;
}
}
/// Clear all cached analytics data.
@override
void dispose() {
constructListModel = ConstructListModel(uses: []);
_analyticsUpdateSubscription?.cancel();
_analyticsUpdateSubscription = null;
_joinSpaceSubscription?.cancel();
_joinSpaceSubscription = null;
initCompleter = Completer<void>();
_pangeaController.putAnalytics.savedActivitiesNotifier
.removeListener(_onActivityAnalyticsUpdate);
_pangeaController.putAnalytics.blockedConstructsNotifier
.removeListener(_onBlockedConstructsUpdate);
_cache.clear();
}
Future<void> _onAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
) async {
if (analyticsUpdate.isLogout) return;
final oldLevel = constructListModel.level;
final offset =
_pangeaController.userController.analyticsProfile?.xpOffset ?? 0;
final prevUnlockedMorphs = constructListModel
.unlockedLemmas(
ConstructTypeEnum.morph,
threshold: 30,
)
.toSet();
constructListModel.updateConstructs(analyticsUpdate.newConstructs, offset);
final newUnlockedMorphs = constructListModel
.unlockedLemmas(
ConstructTypeEnum.morph,
threshold: 30,
)
.toSet()
.difference(prevUnlockedMorphs);
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await _getConstructs(forceUpdate: true);
}
if (oldLevel < constructListModel.level) {
// do not await this - it's not necessary for this to finish
// before the function completes and it blocks the UI
_onLevelUp(oldLevel, constructListModel.level);
}
if (oldLevel > constructListModel.level) {
await _onLevelDown(constructListModel.level, oldLevel);
}
if (newUnlockedMorphs.isNotEmpty) {
_onUnlockMorphLemmas(newUnlockedMorphs);
}
_updateAnalyticsStream(
AnalyticsStreamUpdate(
points: analyticsUpdate.newConstructs.fold<int>(
0,
(previousValue, element) => previousValue + element.xp,
),
targetID: analyticsUpdate.targetID,
),
);
// 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.
// Do this on all updates (not just on level updates) to account for cases
// of target language updates being missed (https://github.com/pangeachat/client/issues/2006)
_pangeaController.userController.updateAnalyticsProfile(
level: constructListModel.level,
);
}
void _updateAnalyticsStream(AnalyticsStreamUpdate update) =>
analyticsStream.add(update);
void _onLevelUp(final int lowerLevel, final int upperLevel) {
setState({
'level_up': constructListModel.level,
'upper_level': upperLevel,
'lower_level': lowerLevel,
});
}
Future<void> _onLevelDown(final int lowerLevel, final int upperLevel) async {
final offset = constructListModel.calculateXpWithLevel(lowerLevel) -
constructListModel.totalXP;
await _pangeaController.userController.addXPOffset(offset);
constructListModel.updateConstructs(
[],
_pangeaController.userController.analyticsProfile!.xpOffset!,
);
}
void _onUnlockMorphLemmas(Set<ConstructIdentifier> unlocked) {
const excludedLemmas = {'not_proper'};
final filtered = {
for (final id in unlocked)
if (!excludedLemmas.contains(id.lemma.toLowerCase())) id,
};
setState({'unlocked_constructs': filtered});
}
void _onActivityAnalyticsUpdate() =>
_updateAnalyticsStream(AnalyticsStreamUpdate());
void _onBlockedConstructsUpdate() {
final constructId =
_pangeaController.putAnalytics.blockedConstructsNotifier.value;
if (constructId == null) return;
constructListModel.deleteConstruct(
constructId,
_pangeaController.userController.analyticsProfile?.xpOffset ?? 0,
);
_updateAnalyticsStream(AnalyticsStreamUpdate());
}
/// A local cache of eventIds and construct uses for messages sent since the last update.
/// It's a map of eventIDs to a list of OneConstructUses. Not just a list of OneConstructUses
/// because, with practice activity constructs, we might need to add to the list for a given
/// eventID.
Map<String, List<OneConstructUse>> get messagesSinceUpdate {
try {
final dynamic locallySaved = analyticsBox.read(
PLocalKey.messagesSinceUpdate,
);
if (locallySaved == null) return {};
try {
// try to get the local cache of messages and format them as OneConstructUses
final Map<String, List<dynamic>> cache =
Map<String, List<dynamic>>.from(locallySaved);
final Map<String, List<OneConstructUse>> formattedCache = {};
for (final entry in cache.entries) {
try {
formattedCache[entry.key] =
entry.value.map((e) => OneConstructUse.fromJson(e)).toList();
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"key": entry.key,
},
);
continue;
}
}
return formattedCache;
} catch (err) {
// if something goes wrong while trying to format the local data, clear it
clearMessagesCache();
return {};
}
} catch (exception, stackTrace) {
ErrorHandler.logError(
e: PangeaWarningError(
"Failed to get messages since update: $exception",
),
s: stackTrace,
m: 'Failed to retrieve messages since update',
data: {
"messagesSinceUpdate": PLocalKey.messagesSinceUpdate,
},
);
return {};
}
}
Future<void> clearMessagesCache() async =>
analyticsBox.remove(PLocalKey.messagesSinceUpdate);
Future<void> setMessagesCache(Map<dynamic, dynamic> cacheValue) async =>
analyticsBox.write(
PLocalKey.messagesSinceUpdate,
cacheValue,
);
/// A flat list of all locally cached construct uses
List<OneConstructUse> get _locallyCachedConstructs =>
messagesSinceUpdate.values.expand((e) => e).toList();
/// A flat list of all locally cached construct uses that are not drafts
List<OneConstructUse> get locallyCachedSentConstructs =>
messagesSinceUpdate.entries
.where((entry) => !entry.key.startsWith('draft'))
.expand((e) => e.value)
.toList();
/// Get a list of all constructs used by the logged in user in their current L2
Future<List<OneConstructUse>> _getConstructs({
bool forceUpdate = false,
ConstructTypeEnum? constructType,
}) async {
// if the user isn't logged in, return an empty list
if (_client.userID == null) return [];
if (_client.prevBatch == null) {
await _client.onSync.stream.first;
}
// don't try to get constructs until last updated time has been loaded
await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
// if forcing a refreshing, clear the cache
if (forceUpdate) _cache.clear();
final List<OneConstructUse>? local = _getConstructsLocal(
constructType: constructType,
);
if (local != null) {
debugPrint("returning local constructs");
return local;
}
debugPrint("fetching new constructs");
// if there is no cached data (or if force updating),
// get all the construct events for the user from analytics room
// and convert their content into a list of construct uses
final List<ConstructAnalyticsEvent> constructEvents =
await _allMyConstructs();
final List<OneConstructUse> uses = [];
for (final event in constructEvents) {
uses.addAll(event.content.uses);
}
// if there isn't already a valid, local cache, cache the filtered uses
if (local == null) {
_cacheConstructs(
constructType: constructType,
uses: uses,
);
}
return uses;
}
/// Get the last time the user updated their analytics for their current l2
Future<DateTime?> myAnalyticsLastUpdated() async {
// this function gets called soon after login, so first
// make sure that the user's l2 is loaded, if the user has set their l2
if (_client.userID != null && _l2 == null) {
if (_client.prevBatch == null) {
await _client.onSync.stream.first;
}
if (_l2 == null) return null;
}
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) return null;
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
_client.userID!,
);
return lastUpdated;
}
/// Get all the construct analytics events for the logged in user
Future<List<ConstructAnalyticsEvent>> _allMyConstructs() async {
if (_l2 == null) return [];
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) return [];
return await analyticsRoom.getAnalyticsEvents(userId: _client.userID!) ??
[];
}
/// Get the cached construct uses for the current user, if it exists
List<OneConstructUse>? _getConstructsLocal({
ConstructTypeEnum? constructType,
}) {
final index = _cache.indexWhere(
(e) => e.type == constructType && e.langCode == _l2?.langCodeShort,
);
if (index > -1) {
final DateTime? lastUpdated = _pangeaController.putAnalytics.lastUpdated;
if (_cache[index].needsUpdate(lastUpdated)) {
_cache.removeAt(index);
return null;
}
return _cache[index].uses;
}
return null;
}
/// Cache the construct uses for the current user
void _cacheConstructs({
required List<OneConstructUse> uses,
ConstructTypeEnum? constructType,
}) {
if (_l2 == null) return;
final entry = AnalyticsCacheEntry(
type: constructType,
uses: List.from(uses),
langCode: _l2!.langCodeShort,
);
_cache.add(entry);
}
Future<String> _saveConstructSummaryResponseToStateEvent(
final ConstructSummary summary,
) async {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
final stateEventId = await _client.setRoomStateWithKey(
analyticsRoom!.id,
PangeaEventTypes.constructSummary,
'',
summary.toJson(),
);
return stateEventId;
}
int newConstructCount(
List<OneConstructUse> newConstructs,
ConstructTypeEnum type,
) {
final uses = newConstructs.where((c) => c.constructType == type);
final Map<ConstructIdentifier, int> constructPoints = {};
for (final use in uses) {
constructPoints[use.identifier] ??= 0;
constructPoints[use.identifier] =
constructPoints[use.identifier]! + use.xp;
}
int newConstructCount = 0;
for (final entry in constructPoints.entries) {
final construct = constructListModel.getConstructUses(entry.key);
if (construct == null || construct.points == entry.value) {
newConstructCount++;
}
}
return newConstructCount;
}
ConstructSummary? getConstructSummaryFromStateEvent() {
try {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) {
debugPrint("Analytics room is null");
return null;
}
final state =
analyticsRoom.getState(PangeaEventTypes.constructSummary, '');
if (state == null) return null;
return ConstructSummary.fromJson(state.content);
} catch (e) {
debugPrint("Error getting construct summary room: $e");
ErrorHandler.logError(e: e, data: {'e': e});
return null;
}
}
Future<ConstructSummary> generateLevelUpAnalytics(
final int lowerLevel,
final int upperLevel,
) async {
final int maxXP = constructListModel.calculateXpWithLevel(upperLevel);
final int minXP = constructListModel.calculateXpWithLevel(lowerLevel);
int diffXP = maxXP - minXP;
if (diffXP < 0) diffXP = 0;
// compute construct use of current level
final List<OneConstructUse> constructUseOfCurrentLevel = [];
int score = constructListModel.totalXP;
for (final use in constructListModel.uses) {
constructUseOfCurrentLevel.add(use);
score -= use.xp;
if (score <= minXP) break;
}
// extract construct use message bodies for analytics
final Map<String, Set<String>> useEventIds = {};
for (final use in constructUseOfCurrentLevel) {
if (use.metadata.roomId == null) continue;
if (use.metadata.eventId == null) continue;
useEventIds[use.metadata.roomId!] ??= {};
useEventIds[use.metadata.roomId!]!.add(use.metadata.eventId!);
}
final List<Map<String, dynamic>> messages = [];
for (final entry in useEventIds.entries) {
final String roomId = entry.key;
final room = _client.getRoomById(roomId);
if (room == null) continue;
final timeline = await room.getTimeline();
for (final eventId in entry.value) {
try {
final Event? event = await room.getEventById(eventId);
if (event == null) continue;
final pangeaMessageEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: room.client.userID == event.senderId,
);
final Map<String, String?> entry = {
"sent": pangeaMessageEvent.originalSent?.text ??
pangeaMessageEvent.body,
"written": pangeaMessageEvent.originalWrittenContent,
};
messages.add(entry);
} catch (e, s) {
debugPrint("Error getting event by ID: $e");
ErrorHandler.logError(
e: e,
s: s,
data: {
'roomId': roomId,
'eventId': eventId,
},
);
continue;
}
}
}
final request = ConstructSummaryRequest(
constructs: constructUseOfCurrentLevel,
messages: messages,
userL1: _l1!.langCodeShort,
userL2: _l2!.langCodeShort,
upperLevel: upperLevel,
lowerLevel: lowerLevel,
);
final response = await ConstructRepo.generateConstructSummary(request);
final ConstructSummary summary = response.summary;
summary.levelVocabConstructs = MatrixState
.pangeaController.getAnalytics.constructListModel
.numConstructs(ConstructTypeEnum.vocab);
summary.levelGrammarConstructs = MatrixState
.pangeaController.getAnalytics.constructListModel
.numConstructs(ConstructTypeEnum.morph);
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(_l2!);
if (analyticsRoom == null) {
throw "Analytics room not found for user";
}
// don't await this, just return the original response
_saveConstructSummaryResponseToStateEvent(
summary,
);
return summary;
}
List<Room> get archivedActivities {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) return [];
final ids = analyticsRoom.activityRoomIds;
return ids
.map((id) => _client.getRoomById(id))
.whereType<Room>()
.where(
(room) =>
room.membership != Membership.leave &&
room.membership != Membership.ban,
)
.toList();
}
int get archivedActivitiesCount {
return archivedActivities.length;
}
}
class AnalyticsCacheEntry {
final String langCode;
final ConstructTypeEnum? type;
final List<OneConstructUse> uses;
late final DateTime _createdAt;
AnalyticsCacheEntry({
required this.langCode,
required this.type,
required this.uses,
}) {
_createdAt = DateTime.now();
}
bool needsUpdate(DateTime? lastEventUpdated) {
// cache entry is invalid if it's older than the last event update
// if lastEventUpdated is null, that would indicate that no events
// of this type have been sent to the room. In this case, there
// shouldn't be any cached data.
if (lastEventUpdated == null) {
Sentry.addBreadcrumb(
Breadcrumb(message: "lastEventUpdated is null in needsUpdate"),
);
return false;
}
return _createdAt.isBefore(lastEventUpdated);
}
}
class AnalyticsStreamUpdate {
final int points;
final String? targetID;
AnalyticsStreamUpdate({
this.points = 0,
this.targetID,
});
}

View file

@ -18,16 +18,16 @@ mixin LemmaEmojiSetter {
String emoji,
String? targetId,
) async {
if (constructId.userSetEmoji.isEmpty) {
_sendEmojiAnalytics(
if (constructId.userSetEmoji == null) {
_getEmojiAnalytics(
constructId,
targetId: targetId,
);
}
await constructId.setUserLemmaInfo(
constructId.userLemmaInfo.copyWith(emojis: [emoji]),
);
await MatrixState
.pangeaController.matrixState.analyticsDataService.updateService
.setLemmaInfo(constructId, emoji: emoji);
}
void showLemmaEmojiSnackbar(
@ -46,14 +46,7 @@ mixin LemmaEmojiSetter {
children: [
VocabAnalyticsListTile(
constructId: constructId,
emoji: emoji,
textColor: Theme.of(context).colorScheme.surface,
icon: Text(
emoji,
style: const TextStyle(
fontSize: 22,
),
),
onTap: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
context.go(
@ -83,7 +76,7 @@ mixin LemmaEmojiSetter {
);
}
void _sendEmojiAnalytics(
void _getEmojiAnalytics(
ConstructIdentifier constructId, {
String? eventId,
String? roomId,
@ -105,11 +98,10 @@ mixin LemmaEmojiSetter {
),
];
MatrixState.pangeaController.putAnalytics.addAnalytics(
MatrixState.pangeaController.matrixState.analyticsDataService.updateService
.addAnalytics(
targetId,
constructs,
eventId: eventId,
roomId: roomId,
targetId: targetId,
);
}
}

View file

@ -1,87 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/widgets/matrix.dart';
// New component renamed to ConstructSummaryAlertDialog with a max width
class ConstructSummaryAlertDialog extends StatelessWidget {
final String title;
final String content;
const ConstructSummaryAlertDialog({
super.key,
required this.title,
required this.content,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(title),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Text(content),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(L10n.of(context).close),
),
],
);
}
}
class LevelSummaryDialog extends StatelessWidget {
final int level;
final String analyticsRoomId;
final String summaryStateEventId;
final ConstructSummary? constructSummary;
const LevelSummaryDialog({
super.key,
required this.analyticsRoomId,
required this.level,
required this.summaryStateEventId,
this.constructSummary,
});
@override
Widget build(BuildContext context) {
final Client client = Matrix.of(context).client;
final futureSummary = client
.getOneRoomEvent(analyticsRoomId, summaryStateEventId)
.then((rawEvent) => ConstructSummary.fromJson(rawEvent.content));
if (constructSummary != null) {
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: constructSummary!.textSummary,
);
} else {
return FutureBuilder<ConstructSummary>(
future: futureSummary,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} else if (snapshot.hasError) {
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: L10n.of(context).error502504Desc,
);
} else if (snapshot.hasData) {
final constructSummary = snapshot.data!;
return ConstructSummaryAlertDialog(
title: L10n.of(context).levelSummaryPopupTitle(level),
content: constructSummary.textSummary,
);
} else {
return const SizedBox.shrink();
}
},
);
}
}
}

View file

@ -0,0 +1,27 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
extension LevelSummaryExtension on Room {
ConstructSummary? get levelUpSummary {
final summaryEvent = getState(PangeaEventTypes.constructSummary);
if (summaryEvent != null) {
return ConstructSummary.fromJson(summaryEvent.content);
}
return null;
}
DateTime? get lastLevelUpTimestamp {
final lastLevelUp = getState(PangeaEventTypes.constructSummary);
return lastLevelUp is Event ? lastLevelUp.originServerTs : null;
}
Future<void> setLevelUpSummary(ConstructSummary summary) =>
client.setRoomStateWithKey(
id,
PangeaEventTypes.constructSummary,
'',
summary.toJson(),
);
}

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
class LevelPopupProgressBar extends StatefulWidget {
final double height;

View file

@ -9,6 +9,8 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
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/overlay.dart';
@ -105,9 +107,11 @@ class LevelUpBannerState extends State<LevelUpBanner>
_loadConstructSummary();
final analyticsService = Matrix.of(context).analyticsDataService;
LevelUpManager.instance.preloadAnalytics(
widget.level,
widget.prevLevel,
analyticsService,
);
_slideController = AnimationController(
@ -162,9 +166,19 @@ class LevelUpBannerState extends State<LevelUpBanner>
Future<void> _loadConstructSummary() async {
try {
final summary = MatrixState.pangeaController.getAnalytics
.generateLevelUpAnalytics(widget.prevLevel, widget.level);
final analyticsRoom = await Matrix.of(context).client.getMyAnalyticsRoom(
MatrixState.pangeaController.userController.userL2!,
);
final timestamp = analyticsRoom!.lastLevelUpTimestamp;
final analyticsService = Matrix.of(context).analyticsDataService;
final summary = await analyticsService.levelUpService.getLevelUpAnalytics(
widget.prevLevel,
widget.level,
timestamp,
);
_constructSummaryCompleter.complete(summary);
analyticsRoom.setLevelUpSummary(summary);
} catch (e) {
debugPrint("Error generating level up analytics: $e");
_constructSummaryCompleter.completeError(e);

View file

@ -1,9 +1,9 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -27,6 +27,7 @@ class LevelUpManager {
Future<void> preloadAnalytics(
int level,
int prevLevel,
AnalyticsDataService analyticsService,
) async {
this.level = level;
this.prevLevel = prevLevel;
@ -34,10 +35,8 @@ class LevelUpManager {
//For on route change behavior, if added in the future
shouldAutoPopup = true;
nextGrammar = MatrixState.pangeaController.getAnalytics.constructListModel
.numConstructs(ConstructTypeEnum.morph);
nextVocab = MatrixState.pangeaController.getAnalytics.constructListModel
.numConstructs(ConstructTypeEnum.vocab);
nextGrammar = analyticsService.numConstructs(ConstructTypeEnum.morph);
nextVocab = analyticsService.numConstructs(ConstructTypeEnum.vocab);
final LanguageModel? l2 =
MatrixState.pangeaController.userController.userL2;
@ -45,25 +44,7 @@ class LevelUpManager {
MatrixState.pangeaController.matrixState.client.analyticsRoomLocal(l2!);
if (analyticsRoom != null) {
// How to get all summary events in the timeline
final timeline = await analyticsRoom.getTimeline();
final summaryEvents = timeline.events
.where(
(e) => e.type == PangeaEventTypes.constructSummary,
)
.map(
(e) => ConstructSummary.fromJson(e.content),
)
.toList();
//Find previous summary to get grammar constructs and vocab numbers from
final lastSummary = summaryEvents
.where((summary) => summary.upperLevel == prevLevel)
.toList()
.isNotEmpty
? summaryEvents
.firstWhere((summary) => summary.upperLevel == prevLevel)
: null;
final lastSummary = analyticsRoom.levelUpSummary;
//Set grammar and vocab from last level summary, if there is one. Otherwise set to placeholder data
if (lastSummary != null &&

View file

@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
class MessageAnalyticsFeedback extends StatefulWidget {
final int newGrammarConstructs;

View file

@ -1,330 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/user/user_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum AnalyticsUpdateType { server, local }
/// handles the processing of analytics for
/// 1) messages sent by the user and
/// 2) constructs used by the user, both in sending messages and doing practice activities
class PutAnalyticsController {
late PangeaController _pangeaController;
StreamController<AnalyticsUpdate> analyticsUpdateStream =
StreamController.broadcast();
ValueNotifier<List<String>> savedActivitiesNotifier = ValueNotifier([]);
ValueNotifier<ConstructIdentifier?> blockedConstructsNotifier =
ValueNotifier(null);
StreamSubscription? _languageStream;
Timer? _updateTimer;
Client get _client => _pangeaController.matrixState.client;
/// the last time that matrix analytics events were updated for the user's current l2
DateTime? lastUpdated;
/// Last updated completer. Used to wait for the last
/// updated time to be set before setting analytics data.
Completer<DateTime?> lastUpdatedCompleter = Completer<DateTime?>();
/// the max number of messages that will be cached before
/// an automatic update is triggered
final int _maxMessagesCached = 10;
/// the number of minutes before an automatic update is triggered
final int _minutesBeforeUpdate = 5;
/// the time since the last update that will trigger an automatic update
final Duration _timeSinceUpdate = const Duration(days: 1);
PutAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
void initialize() {
_languageStream ??= _pangeaController.userController.languageStream.stream
.listen(_onUpdateLanguages);
_refreshAnalyticsIfOutdated();
}
/// Reset analytics last updated time to null.
void dispose() {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_languageStream?.cancel();
_languageStream = null;
MatrixState.pangeaController.getAnalytics.clearMessagesCache();
}
/// If analytics haven't been updated in the last day, update them
Future<void> _refreshAnalyticsIfOutdated() async {
// don't set anything is the user is not logged in
if (_client.userID == null) return;
try {
// if lastUpdated hasn't been set yet, set it
lastUpdated ??=
await _pangeaController.getAnalytics.myAnalyticsLastUpdated();
} catch (err, s) {
ErrorHandler.logError(
s: s,
e: err,
m: "Failed to get last updated time for analytics",
data: {},
);
} finally {
// if this is the initial load, complete the lastUpdatedCompleter
if (!lastUpdatedCompleter.isCompleted) {
lastUpdatedCompleter.complete(lastUpdated);
}
}
final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate);
if (lastUpdated?.isBefore(yesterday) ?? true) {
debugPrint("analytics out-of-date, updating");
await sendLocalAnalyticsToAnalyticsRoom();
}
}
/// Given new construct uses, format and cache
/// the data locally and reset the update timer
/// Decide whether to update the analytics room
void addAnalytics(
List<OneConstructUse> constructs, {
String? eventId,
String? roomId,
String? targetId,
}) {
final level = _pangeaController.getAnalytics.constructListModel.level;
_addLocalMessage(eventId, List.from(constructs)).then(
(_) => _sendAnalytics(level, targetId, constructs),
);
}
/// Add a list of construct uses for a new message to the local
/// cache of recently sent messages
Future<void> _addLocalMessage(
String? cacheKey,
List<OneConstructUse> constructs,
) async {
try {
final currentCache = _pangeaController.getAnalytics.messagesSinceUpdate;
constructs.addAll(currentCache[cacheKey] ?? []);
// if this is not a draft message, add the eventId to the metadata
// if it's missing (it will be missing for draft constructs)
if (cacheKey != null) {
constructs = constructs.map((construct) {
if (construct.metadata.eventId != null) return construct;
construct.metadata.eventId = cacheKey;
return construct;
}).toList();
}
cacheKey ??= Object.hashAll(constructs).toString();
currentCache[cacheKey] = constructs;
await _setMessagesSinceUpdate(currentCache);
} catch (e, s) {
ErrorHandler.logError(
e: PangeaWarningError("Failed to add message since update: $e"),
s: s,
m: 'Failed to add message since update for eventId: $cacheKey',
data: {
"cacheKey": cacheKey,
},
);
}
}
/// Handles cleanup after adding a new message to the local cache.
/// If the addition brought the total number of messages in the cache
/// to the max, or if the addition triggered a level-up, update the analytics.
/// Otherwise, add a local update to the alert stream.
void _sendAnalytics(
int prevLevel,
String? targetID,
List<OneConstructUse> newConstructs,
) {
// cancel the last timer that was set on message event and
// reset it to fire after _minutesBeforeUpdate minutes
_updateTimer?.cancel();
_updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () {
debugPrint("timer fired, updating analytics");
sendLocalAnalyticsToAnalyticsRoom();
});
if (_pangeaController.getAnalytics.messagesSinceUpdate.length >
_maxMessagesCached) {
debugPrint("reached max messages, updating");
sendLocalAnalyticsToAnalyticsRoom();
return;
}
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.local,
newConstructs,
targetID: targetID,
),
);
}
Future<void> _onUpdateLanguages(LanguageUpdate update) async {
await sendLocalAnalyticsToAnalyticsRoom(
l2Override: update.prevTargetLang,
);
_pangeaController.resetAnalytics().then((_) {
final level = _pangeaController.getAnalytics.constructListModel.level;
_pangeaController.userController.updateAnalyticsProfile(level: level);
});
}
/// Save the local cache of recently sent constructs to the local storage
Future<void> _setMessagesSinceUpdate(
Map<String, List<OneConstructUse>> cache,
) async {
final formattedCache = {};
for (final entry in cache.entries) {
final constructJsons = entry.value.map((e) => e.toJson()).toList();
formattedCache[entry.key] = constructJsons;
}
await MatrixState.pangeaController.getAnalytics
.setMessagesCache(formattedCache);
}
/// Prevent concurrent updates to analytics
Completer<void>? _updateCompleter;
/// Updates learning analytics.
///
/// This method is responsible for updating the analytics. It first checks if an update is already in progress
/// by checking the completion status of the [_updateCompleter]. If an update is already in progress, it waits
/// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and
/// proceeds with the update process. If the update is successful, it clears any messages that were received
/// since the last update and notifies the [analyticsUpdateStream].
Future<void> sendLocalAnalyticsToAnalyticsRoom({
onLogout = false,
LanguageModel? l2Override,
}) async {
if (_client.userID == null) return;
if (_pangeaController.getAnalytics.messagesSinceUpdate.isEmpty) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
return;
}
_updateCompleter = Completer<void>();
try {
await _updateAnalytics(l2Override: l2Override);
MatrixState.pangeaController.getAnalytics.clearMessagesCache();
lastUpdated = DateTime.now();
analyticsUpdateStream.add(
AnalyticsUpdate(
AnalyticsUpdateType.server,
[],
isLogout: onLogout,
),
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
m: "Failed to update analytics",
s: s,
data: {
"l2Override": l2Override,
},
);
} finally {
_updateCompleter?.complete();
_updateCompleter = null;
}
}
/// Updates the analytics by sending cached analytics data to the analytics room.
/// The analytics room is determined based on the user's current target language.
Future<void> _updateAnalytics({LanguageModel? l2Override}) async {
// if there's no cached construct data, there's nothing to send
final cachedConstructs = _pangeaController.getAnalytics.messagesSinceUpdate;
final bool onlyDraft = cachedConstructs.length == 1 &&
cachedConstructs.keys.single.startsWith('draft');
if (cachedConstructs.isEmpty || onlyDraft) return;
// if missing important info, don't send analytics. Could happen if user just signed up.
final l2 = l2Override ?? _pangeaController.userController.userL2;
if (l2 == null || _client.userID == null) return;
// analytics room for the user and current target language
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(l2);
// and send cached analytics data to the room
await analyticsRoom?.sendConstructsEvent(
_pangeaController.getAnalytics.locallyCachedSentConstructs,
);
}
Future<void> sendActivityAnalytics(String roomId) async {
if (_client.userID == null) return;
if (_pangeaController.userController.userL2 == null) return;
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(
_pangeaController.userController.userL2!,
);
if (analyticsRoom == null) return;
await analyticsRoom.addActivityRoomId(roomId);
savedActivitiesNotifier.value = analyticsRoom.activityRoomIds;
}
Future<void> blockConstruct(ConstructIdentifier constructId) async {
if (_pangeaController.matrixState.client.userID == null) return;
if (_pangeaController.userController.userL2 == null) return;
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(
_pangeaController.userController.userL2!,
);
if (analyticsRoom == null) return;
final current = analyticsRoom.analyticsSettings ??
const AnalyticsSettingsModel(blockedConstructs: {});
final blockedConstructs = current.blockedConstructs;
final updated = current.copyWith(
blockedConstructs: {
...blockedConstructs,
constructId,
},
);
await analyticsRoom.setAnalyticsSettings(updated);
blockedConstructsNotifier.value = constructId;
}
}
class AnalyticsUpdate {
final AnalyticsUpdateType type;
final List<OneConstructUse> newConstructs;
final bool isLogout;
final String? targetID;
AnalyticsUpdate(
this.type,
this.newConstructs, {
this.isLogout = false,
this.targetID,
});
}

View file

@ -1,184 +1,16 @@
part of "../extensions/pangea_room_extension.dart";
extension AnalyticsRoomExtension on Room {
/// Get next n analytics rooms via the space hierarchy
/// If joined
/// If not in target language
/// If not created by user, leave
/// Else, add to list
/// Else
/// If room name does not match L2, skip
/// Join and wait for room in sync.
/// Repeat the same procedure as above.
///
/// If not n analytics rooms in list, and nextBatch != null, repeat the above
/// procedure with nextBatch until n analytics rooms are found or nextBatch == null
///
/// Yield this list of rooms.
/// Once analytics have been retrieved, leave analytics rooms not created by self.
Stream<List<Room>> getNextAnalyticsRoomBatch(String langCode) async* {
final List<SpaceRoomsChunk> rooms = [];
String? nextBatch;
int spaceHierarchyCalls = 0;
int callsToServer = 0;
while (spaceHierarchyCalls <= 5 &&
(nextBatch != null || spaceHierarchyCalls == 0)) {
spaceHierarchyCalls++;
final resp = await _getNextBatch(nextBatch);
callsToServer++;
if (resp == null) return;
rooms.addAll(resp.rooms);
nextBatch = resp.nextBatch;
final List<Room> roomsBatch = [];
while (rooms.isNotEmpty) {
// prevent rate-limiting
if (callsToServer >= 5) {
callsToServer = 0;
await Future.delayed(const Duration(milliseconds: 7500));
}
final nextRoomChunk = rooms.removeAt(0);
if (nextRoomChunk.roomType != PangeaRoomTypes.analytics) {
continue;
}
final matchingRoom = client.rooms.firstWhereOrNull(
(r) => r.id == nextRoomChunk.roomId,
);
final (analyticsRoom, calls) = matchingRoom != null
? await _handleJoinedAnalyticsRoom(matchingRoom, langCode)
: await _handleUnjoinedAnalyticsRoom(nextRoomChunk, langCode);
callsToServer += calls;
if (analyticsRoom == null) continue;
roomsBatch.add(analyticsRoom);
if (roomsBatch.length >= 5) {
final roomsBatchCopy = List<Room>.from(roomsBatch);
roomsBatch.clear();
yield roomsBatchCopy;
}
}
yield roomsBatch;
}
String? get madeForLang {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) ??
creationContent?.tryGet<String>(ModelKey.oldLangCode);
}
/// Return analytics room, given unjoined member of space hierarchy,
/// if should get analytics for that room, and number of call made
/// to the server to help prevent rate-limiting
Future<(Room?, int)> _handleUnjoinedAnalyticsRoom(
SpaceRoomsChunk chunk,
String l2,
) async {
int callsToServer = 0;
final nameParts = chunk.name?.split(" ");
if (nameParts != null && nameParts.length >= 2) {
final roomLangCode = nameParts[1];
if (roomLangCode != l2) return (null, callsToServer);
}
Room? analyticsRoom = await _joinAnalyticsRoomChunk(chunk);
callsToServer++;
if (analyticsRoom == null) return (null, callsToServer);
final (room, calls) = await _handleJoinedAnalyticsRoom(analyticsRoom, l2);
analyticsRoom = room;
callsToServer += calls;
return (analyticsRoom, callsToServer);
}
/// Return analytics room if should add to returned list
/// and the number of calls made to the server (used to prevent rate-limiting)
Future<(Room?, int)> _handleJoinedAnalyticsRoom(
Room analyticsRoom,
String l2,
) async {
if (client.userID == null) return (null, 0);
if (analyticsRoom.madeForLang != l2) {
await _leaveNonTargetAnalyticsRoom(analyticsRoom, l2);
return (null, 1);
}
return (analyticsRoom, 0);
}
Future<Room?> _joinAnalyticsRoomChunk(
SpaceRoomsChunk chunk,
) async {
final matchingRoom = client.rooms.firstWhereOrNull(
(r) => r.id == chunk.roomId,
);
if (matchingRoom != null) return matchingRoom;
try {
final syncFuture = client.waitForRoomInSync(chunk.roomId, join: true);
await client.joinRoom(chunk.roomId);
await syncFuture;
return client.getRoomById(chunk.roomId);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": chunk.roomId,
},
);
return null;
}
}
Future<void> _leaveNonTargetAnalyticsRoom(Room room, String userL2) async {
if (client.userID == null ||
room.isMadeByUser(client.userID!) ||
room.madeForLang == userL2) {
return;
}
try {
await room.leave();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": room.id,
},
);
}
}
Future<GetSpaceHierarchyResponse?> _getNextBatch(String? nextBatch) async {
try {
final resp = await client.getSpaceHierarchy(
id,
from: nextBatch,
limit: 100,
maxDepth: 1,
);
return resp;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"spaceID": id,
"nextBatch": nextBatch,
},
);
return null;
}
}
Future<DateTime?> analyticsLastUpdated(String userId) async {
final List<Event> events =
await getRoomAnalyticsEvents(count: 1, userID: userId);
if (events.isEmpty) return null;
return events.first.originServerTs;
bool isMadeForLang(String langCode) {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) == langCode ||
creationContent?.tryGet<String>(ModelKey.oldLangCode) == langCode;
}
Future<List<ConstructAnalyticsEvent>?> getAnalyticsEvents({
@ -194,18 +26,6 @@ extension AnalyticsRoomExtension on Room {
return analyticsEvents;
}
String? get madeForLang {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) ??
creationContent?.tryGet<String>(ModelKey.oldLangCode);
}
bool isMadeForLang(String langCode) {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) == langCode ||
creationContent?.tryGet<String>(ModelKey.oldLangCode) == langCode;
}
/// Sends construct events to the server.
///
/// The [uses] parameter is a list of [OneConstructUse] objects representing the
@ -268,87 +88,4 @@ extension AnalyticsRoomExtension on Room {
);
}
}
UserSetLemmaInfo? getUserSetLemmaInfo(ConstructIdentifier cId) {
final state = getState(PangeaEventTypes.userSetLemmaInfo, cId.string);
if (state == null) return null;
try {
return UserSetLemmaInfo.fromJson(state.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateContent": state.content,
"stateKey": state.stateKey,
},
);
return null;
}
}
Future<void> setUserSetLemmaInfo(
ConstructIdentifier cId,
UserSetLemmaInfo info,
) async {
final syncFuture = client.onRoomState.stream.firstWhere((event) {
return event.roomId == id &&
event.state.type == PangeaEventTypes.userSetLemmaInfo;
});
client.setRoomStateWithKey(
id,
PangeaEventTypes.userSetLemmaInfo,
cId.string,
info.toJson(),
);
await syncFuture.timeout(const Duration(seconds: 10));
}
List<String> get activityRoomIds {
final state = getState(PangeaEventTypes.activityRoomIds);
if (state?.content[ModelKey.roomIds] is List) {
return List<String>.from(state!.content[ModelKey.roomIds] as List);
}
return [];
}
Future<void> addActivityRoomId(String roomId) async {
final List<String> ids = List.from(activityRoomIds);
if (ids.contains(roomId)) return;
final prevLength = ids.length;
ids.add(roomId);
final syncFuture = client.waitForRoomInSync(id, join: true);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRoomIds,
"",
{ModelKey.roomIds: ids},
);
final newLength = activityRoomIds.length;
if (newLength == prevLength) {
await syncFuture;
}
}
Future<void> removeActivityRoomId(String roomId) async {
final List<String> ids = List.from(activityRoomIds);
if (!ids.contains(roomId)) return;
final prevLength = ids.length;
ids.remove(roomId);
final syncFuture = client.waitForRoomInSync(id, join: true);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRoomIds,
"",
{ModelKey.roomIds: ids},
);
final newLength = activityRoomIds.length;
if (newLength == prevLength) {
await syncFuture;
}
}
}

View file

@ -0,0 +1,48 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
extension SavedAnalyticsExtension on Room {
List<String> get _activityRoomIds {
final state = getState(PangeaEventTypes.activityRoomIds);
if (state?.content[ModelKey.roomIds] is List) {
return List<String>.from(state!.content[ModelKey.roomIds] as List);
}
return [];
}
List<Room> get archivedActivities {
return _activityRoomIds
.map((id) => client.getRoomById(id))
.whereType<Room>()
.where(
(room) =>
room.membership != Membership.leave &&
room.membership != Membership.ban,
)
.toList();
}
int get archivedActivitiesCount => archivedActivities.length;
Future<void> addActivityRoomId(String roomId) async {
final List<String> ids = List.from(_activityRoomIds);
if (ids.contains(roomId)) return;
final prevLength = ids.length;
ids.add(roomId);
final syncFuture = client.waitForRoomInSync(id, join: true);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRoomIds,
"",
{ModelKey.roomIds: ids},
);
final newLength = _activityRoomIds.length;
if (newLength == prevLength) {
await syncFuture;
}
}
}

View file

@ -0,0 +1,49 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
extension UserLemmaInfoExtension on Room {
UserSetLemmaInfo getUserSetLemmaInfo(ConstructIdentifier cId) {
final state = getState(PangeaEventTypes.userSetLemmaInfo, cId.string);
if (state == null) return UserSetLemmaInfo();
try {
return UserSetLemmaInfo.fromJson(state.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateContent": state.content,
"stateKey": state.stateKey,
},
);
return UserSetLemmaInfo();
}
}
String? constructEmoji(ConstructIdentifier cId) {
final info = getUserSetLemmaInfo(cId);
return info.emojis?.firstOrNull;
}
Future<void> setUserSetLemmaInfo(
ConstructIdentifier cId,
UserSetLemmaInfo info,
) async {
final syncFuture = client.onRoomState.stream.firstWhere((event) {
return event.roomId == id &&
event.state.type == PangeaEventTypes.userSetLemmaInfo;
});
client.setRoomStateWithKey(
id,
PangeaEventTypes.userSetLemmaInfo,
cId.string,
info.toJson(),
);
await syncFuture.timeout(const Duration(seconds: 10));
}
}

View file

@ -6,6 +6,8 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
@ -21,11 +23,10 @@ class ActivityArchive extends StatelessWidget {
this.selectedRoomId,
});
List<Room> get archive =>
MatrixState.pangeaController.getAnalytics.archivedActivities;
@override
Widget build(BuildContext context) {
final Room? analyticsRoom = Matrix.of(context).client.analyticsRoomLocal();
final archive = analyticsRoom?.archivedActivities ?? [];
return MaxWidthBody(
withScrolling: false,
child: ListView.builder(

View file

@ -11,14 +11,14 @@ import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_page/activity_archive.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicators.dart';
import 'package:fluffychat/pangea/analytics_summary/level_dialog_content.dart';
import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_content.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsPage extends StatelessWidget {
class AnalyticsPage extends StatefulWidget {
final ProgressIndicatorEnum? indicator;
final ConstructIdentifier? construct;
final bool isSidebar;
@ -30,7 +30,18 @@ class AnalyticsPage extends StatelessWidget {
this.isSidebar = false,
});
Future<void> _blockLemma(BuildContext context) async {
@override
AnalyticsPageState createState() => AnalyticsPageState();
}
class AnalyticsPageState extends State<AnalyticsPage> {
@override
void initState() {
super.initState();
MatrixState.pangeaController.initControllers();
}
Future<void> _blockLemma() async {
final resp = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
@ -41,8 +52,10 @@ class AnalyticsPage extends StatelessWidget {
if (resp != OkCancelResult.ok) return;
final res = await showFutureLoadingDialog(
context: context,
future: () =>
MatrixState.pangeaController.putAnalytics.blockConstruct(construct!),
future: () => Matrix.of(context)
.analyticsDataService
.updateService
.blockConstruct(widget.construct!),
);
if (!res.isError) {
@ -54,79 +67,73 @@ class AnalyticsPage extends StatelessWidget {
Widget build(BuildContext context) {
final analyticsRoomId = GoRouterState.of(context).pathParameters['roomid'];
return Scaffold(
appBar: construct != null
appBar: widget.construct != null
? AppBar(
actions: indicator == ProgressIndicatorEnum.wordsUsed
actions: widget.indicator == ProgressIndicatorEnum.wordsUsed
? [
IconButton(
icon: const Icon(Icons.delete_forever_outlined),
color: Theme.of(context).colorScheme.error,
tooltip: L10n.of(context).delete,
onPressed: () => _blockLemma(context),
onPressed: _blockLemma,
),
]
: null,
)
: null,
body: SafeArea(
child: FutureBuilder(
future:
MatrixState.pangeaController.getAnalytics.initCompleter.future,
builder: (context, snapshot) {
return Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSidebar ||
(!FluffyThemes.isColumnMode(context) &&
construct == null))
LearningProgressIndicators(
selected: indicator,
canSelect: indicator != ProgressIndicatorEnum.level,
),
Expanded(
child: () {
if (indicator == ProgressIndicatorEnum.level) {
return const LevelDialogContent();
} else if (indicator ==
ProgressIndicatorEnum.morphsUsed) {
return ConstructAnalyticsView(
construct: construct,
view: ConstructTypeEnum.morph,
);
} else if (indicator == ProgressIndicatorEnum.wordsUsed) {
return ConstructAnalyticsView(
construct: construct,
view: ConstructTypeEnum.vocab,
);
} else if (indicator ==
ProgressIndicatorEnum.activities) {
return ActivityArchive(
selectedRoomId: analyticsRoomId,
);
}
child: Padding(
padding: const EdgeInsetsGeometry.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.isSidebar ||
(!FluffyThemes.isColumnMode(context) &&
widget.construct == null))
LearningProgressIndicators(
selected: widget.indicator,
canSelect: widget.indicator != ProgressIndicatorEnum.level,
),
Expanded(
child: () {
if (widget.indicator == ProgressIndicatorEnum.level) {
return const LevelAnalyticsDetailsContent();
} else if (widget.indicator ==
ProgressIndicatorEnum.morphsUsed) {
return ConstructAnalyticsView(
construct: widget.construct,
view: ConstructTypeEnum.morph,
);
} else if (widget.indicator ==
ProgressIndicatorEnum.wordsUsed) {
return ConstructAnalyticsView(
construct: widget.construct,
view: ConstructTypeEnum.vocab,
);
} else if (widget.indicator ==
ProgressIndicatorEnum.activities) {
return ActivityArchive(
selectedRoomId: analyticsRoomId,
);
}
return Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, url, error) =>
const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
),
return Center(
child: SizedBox(
width: 250.0,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, url, error) => const SizedBox(),
placeholder: (context, url) => const Center(
child: CircularProgressIndicator.adaptive(),
),
);
}(),
),
],
),
),
);
}(),
),
);
},
],
),
),
),
);

View file

@ -1,15 +1,21 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
extension AnalyticsSettingsRoomExtension on Room {
AnalyticsSettingsModel? get analyticsSettings {
AnalyticsSettingsModel get analyticsSettings {
final event = getState(PangeaEventTypes.analyticsSettings);
if (event == null) return null;
if (event == null) {
return const AnalyticsSettingsModel(blockedConstructs: {});
}
return AnalyticsSettingsModel.fromJson(event.content);
}
Set<ConstructIdentifier> get blockedConstructs =>
analyticsSettings.blockedConstructs;
Future<void> setAnalyticsSettings(
AnalyticsSettingsModel settings,
) async {

View file

@ -1,18 +1,15 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_bar/animated_progress_bar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
class LearningProgressBar extends StatelessWidget {
final int level;
final int totalXP;
final double progress;
final double height;
final bool loading;
const LearningProgressBar({
required this.level,
required this.totalXP,
required this.progress,
required this.loading,
required this.height,
super.key,
@ -32,7 +29,7 @@ class LearningProgressBar extends StatelessWidget {
return AnimatedProgressBar(
height: height,
widthPercent: MatrixState.pangeaController.getAnalytics.levelProgress,
widthPercent: progress,
barColor: AppConfig.goldLight,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
);

View file

@ -1,13 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicator_button.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicator.dart';
@ -20,7 +18,7 @@ import 'package:fluffychat/widgets/matrix.dart';
/// It shows a variety of progress indicators such as
/// messages sent, words used, and error types, which can
/// be clicked to access more fine-grained analytics data.
class LearningProgressIndicators extends StatefulWidget {
class LearningProgressIndicators extends StatelessWidget {
final ProgressIndicatorEnum? selected;
final bool canSelect;
@ -30,67 +28,6 @@ class LearningProgressIndicators extends StatefulWidget {
this.canSelect = true,
});
@override
State<LearningProgressIndicators> createState() =>
LearningProgressIndicatorsState();
}
class LearningProgressIndicatorsState
extends State<LearningProgressIndicators> {
ConstructListModel get _constructsModel =>
MatrixState.pangeaController.getAnalytics.constructListModel;
bool _loading = true;
StreamSubscription<AnalyticsStreamUpdate>? _analyticsSubscription;
StreamSubscription? _languageSubscription;
@override
void initState() {
super.initState();
// if getAnalytics has already finished initializing,
// the data is loaded and should be displayed.
if (MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted) {
updateData();
}
_analyticsSubscription = MatrixState
.pangeaController.getAnalytics.analyticsStream.stream
.listen((_) => updateData());
// rebuild when target language changes
_languageSubscription = MatrixState
.pangeaController.userController.languageStream.stream
.listen((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_analyticsSubscription?.cancel();
_analyticsSubscription = null;
_languageSubscription?.cancel();
_languageSubscription = null;
super.dispose();
}
void updateData() {
if (_loading) _loading = false;
if (mounted) setState(() {});
}
int uniqueLemmas(ProgressIndicatorEnum indicator) {
switch (indicator) {
case ProgressIndicatorEnum.morphsUsed:
return _constructsModel.numConstructs(ConstructTypeEnum.morph);
case ProgressIndicatorEnum.wordsUsed:
return _constructsModel.numConstructs(ConstructTypeEnum.vocab);
default:
return 0;
}
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
@ -98,175 +35,202 @@ class LearningProgressIndicatorsState
return const SizedBox();
}
final userL1 = MatrixState.pangeaController.userController.userL1;
final userL2 = MatrixState.pangeaController.userController.userL2;
final isColumnMode = FluffyThemes.isColumnMode(context);
final analyticsService = Matrix.of(context).analyticsDataService;
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
spacing: isColumnMode ? 16.0 : 4.0,
children: [
...ConstructTypeEnum.values.map(
(c) => HoverButton(
selected: widget.selected == c.indicator,
onPressed: () {
context.go(
"/rooms/analytics/${c.string}",
);
},
child: ProgressIndicatorBadge(
indicator: c.indicator,
loading: _loading,
points: uniqueLemmas(c.indicator),
),
),
),
HoverButton(
selected: widget.selected ==
ProgressIndicatorEnum.activities,
onPressed: () {
context.go(
"/rooms/analytics/activities",
);
},
child: Tooltip(
message: ProgressIndicatorEnum.activities
.tooltip(context),
return StreamBuilder(
stream: MatrixState.pangeaController.userController.languageStream.stream,
builder: (context, _) {
final userL1 = MatrixState.pangeaController.userController.userL1;
final userL2 = MatrixState.pangeaController.userController.userL2;
final analyticsRoom = Matrix.of(context).client.analyticsRoomLocal();
final archivedActivitiesCount =
analyticsRoom?.archivedActivitiesCount ?? 0;
return StreamBuilder(
stream:
analyticsService.updateDispatcher.constructUpdateStream.stream,
builder: (context, _) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: isColumnMode ? 16.0 : 4.0,
children: [
Icon(
size: 18,
Icons.radar,
color: Theme.of(context).colorScheme.primary,
weight: 1000,
...ConstructTypeEnum.values.map(
(c) => HoverButton(
selected: selected == c.indicator,
onPressed: () {
context.go(
"/rooms/analytics/${c.string}",
);
},
child: ProgressIndicatorBadge(
indicator: c.indicator,
loading: analyticsService.isInitializing,
points: analyticsService.numConstructs(c),
),
),
),
const SizedBox(width: 6.0),
AnimatedFloatingNumber(
number: MatrixState.pangeaController
.getAnalytics.archivedActivitiesCount,
HoverButton(
selected: selected ==
ProgressIndicatorEnum.activities,
onPressed: () {
context.go(
"/rooms/analytics/activities",
);
},
child: Tooltip(
message: ProgressIndicatorEnum.activities
.tooltip(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
size: 18,
Icons.radar,
color: Theme.of(context)
.colorScheme
.primary,
weight: 1000,
),
const SizedBox(width: 6.0),
AnimatedFloatingNumber(
number: archivedActivitiesCount,
),
],
),
),
),
],
),
),
),
],
),
),
HoverButton(
onPressed: () => showDialog(
context: context,
builder: (c) => const SettingsLearning(),
barrierDismissible: false,
),
child: Row(
children: [
if (userL1 != null && userL2 != null)
Text(
userL1.langCodeShort.toUpperCase(),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
HoverButton(
onPressed: () => showDialog(
context: context,
builder: (c) => const SettingsLearning(),
barrierDismissible: false,
),
child: Row(
children: [
if (userL1 != null && userL2 != null)
Text(
userL1.langCodeShort.toUpperCase(),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.primary,
),
),
if (userL1 != null && userL2 != null)
const Icon(Icons.chevron_right_outlined),
if (userL2 != null)
Text(
userL2.langCodeShort.toUpperCase(),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.primary,
),
),
],
),
),
if (userL1 != null && userL2 != null)
const Icon(Icons.chevron_right_outlined),
if (userL2 != null)
Text(
userL2.langCodeShort.toUpperCase(),
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
],
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: HoverBuilder(
builder: (context, hovered) {
return Container(
decoration: BoxDecoration(
color: hovered && widget.canSelect
? Theme.of(context)
.colorScheme
.primary
.withAlpha((0.2 * 255).round())
: Colors.transparent,
borderRadius: BorderRadius.circular(36.0),
],
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 4.0,
),
child: MouseRegion(
cursor: widget.canSelect
? SystemMouseCursors.click
: MouseCursor.defer,
child: GestureDetector(
onTap: widget.canSelect
? () {
context.go("/rooms/analytics/level");
}
: null,
child: Row(
spacing: 8.0,
children: [
Expanded(
child: LearningProgressBar(
level: _constructsModel.level,
totalXP: _constructsModel.totalXP,
height: 24.0,
loading: _loading,
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: HoverBuilder(
builder: (context, hovered) {
return Container(
decoration: BoxDecoration(
color: hovered && canSelect
? Theme.of(context)
.colorScheme
.primary
.withAlpha((0.2 * 255).round())
: Colors.transparent,
borderRadius: BorderRadius.circular(36.0),
),
padding: const EdgeInsets.symmetric(
vertical: 2.0,
horizontal: 4.0,
),
child: MouseRegion(
cursor: canSelect
? SystemMouseCursors.click
: MouseCursor.defer,
child: GestureDetector(
onTap: canSelect
? () {
context.go("/rooms/analytics/level");
}
: null,
child: FutureBuilder(
future: analyticsService.derivedData,
builder: (context, snapshot) {
return Row(
spacing: 8.0,
children: [
Expanded(
child: LearningProgressBar(
height: 24.0,
loading: !snapshot.hasData,
progress: snapshot
.data?.levelProgress ??
0.0,
),
),
if (snapshot.hasData)
Text(
"${snapshot.data!.level}",
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.primary,
),
),
],
);
},
),
),
),
if (!_loading)
Text(
"${_constructsModel.level}",
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context)
.colorScheme
.primary,
),
),
],
),
);
},
),
),
);
},
const SizedBox(height: 16.0),
],
),
),
),
const SizedBox(height: 16.0),
],
),
),
],
],
);
},
);
},
);
}
}

View file

@ -0,0 +1,156 @@
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_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelAnalyticsDetailsContent extends StatelessWidget {
const LevelAnalyticsDetailsContent({
super.key,
});
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
final analyticsService = Matrix.of(context).analyticsDataService;
return StreamBuilder(
stream: analyticsService.updateDispatcher.constructUpdateStream.stream,
builder: (context, _) {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
automaticallyImplyLeading: false,
title: FutureBuilder(
future: analyticsService.derivedData,
builder: (context, snapshot) {
final totalXP = snapshot.data?.totalXP ?? 0;
final maxLevelXP = snapshot.data?.minXPForNextLevel ?? 0;
final level = snapshot.data?.level ?? 0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${L10n.of(context).levelShort(level)}",
style: TextStyle(
fontSize: isColumnMode ? 24 : 16,
fontWeight: FontWeight.w900,
color: AppConfig.gold,
),
),
Text(
L10n.of(context).xpIntoLevel(totalXP, maxLevelXP),
style: TextStyle(
fontSize: isColumnMode ? 24 : 16,
fontWeight: FontWeight.w900,
color: AppConfig.gold,
),
),
],
),
);
},
),
),
body: FutureBuilder(
future: analyticsService.getUses(count: 100),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
final uses = snapshot.data!;
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: uses.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.levelAnalytics,
padding: EdgeInsets.symmetric(vertical: 16.0),
);
}
index--;
final use = uses[index];
String lemmaCopy = use.lemma;
if (use.constructType == ConstructTypeEnum.morph) {
lemmaCopy = getGrammarCopy(
category: use.category,
lemma: use.lemma,
context: context,
) ??
use.lemma;
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: Container(
width: 40,
alignment: Alignment.centerLeft,
child: Icon(use.useType.icon),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
"\"$lemmaCopy\" - ${use.useType.description(context)}",
style: const TextStyle(fontSize: 14),
),
),
Container(
alignment: Alignment.topRight,
width: 60,
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${use.xp > 0 ? '+' : ''}${use.xp}",
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 14,
height: 1,
color: use.pointValueColor(context),
),
),
],
),
),
],
),
),
);
},
),
),
],
);
},
),
);
},
);
}
}

View file

@ -1,153 +0,0 @@
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_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_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LevelDialogContent extends StatelessWidget {
const LevelDialogContent({
super.key,
});
GetAnalyticsController get analytics =>
MatrixState.pangeaController.getAnalytics;
int get level => analytics.constructListModel.level;
int get totalXP => analytics.constructListModel.totalXP;
int get maxLevelXP => analytics.minXPForNextLevel;
List<OneConstructUse> get uses => analytics.constructListModel.truncatedUses;
bool get _loading =>
!MatrixState.pangeaController.getAnalytics.initCompleter.isCompleted;
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
return StreamBuilder(
stream: analytics.analyticsStream.stream,
builder: (context, _) {
if (_loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
automaticallyImplyLeading: false,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${L10n.of(context).levelShort(level)}",
style: TextStyle(
fontSize: isColumnMode ? 24 : 16,
fontWeight: FontWeight.w900,
color: AppConfig.gold,
),
),
Text(
L10n.of(context).xpIntoLevel(totalXP, maxLevelXP),
style: TextStyle(
fontSize: isColumnMode ? 24 : 16,
fontWeight: FontWeight.w900,
color: AppConfig.gold,
),
),
],
),
),
),
body: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: uses.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.levelAnalytics,
padding: EdgeInsets.symmetric(vertical: 16.0),
);
}
index--;
final use = uses[index];
String lemmaCopy = use.lemma;
if (use.constructType == ConstructTypeEnum.morph) {
lemmaCopy = getGrammarCopy(
category: use.category,
lemma: use.lemma,
context: context,
) ??
use.lemma;
}
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: Container(
width: 40,
alignment: Alignment.centerLeft,
child: Icon(use.useType.icon),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
"\"$lemmaCopy\" - ${use.useType.description(context)}",
style: const TextStyle(fontSize: 14),
),
),
Container(
alignment: Alignment.topRight,
width: 60,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${use.xp > 0 ? '+' : ''}${use.xp}",
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 14,
height: 1,
color: use.pointValueColor(context),
),
),
],
),
),
],
),
),
);
},
),
),
],
),
);
},
);
}
}

View file

@ -25,9 +25,7 @@ enum ProgressIndicatorEnum {
return null;
}
}
}
extension ProgressIndicatorsExtension on ProgressIndicatorEnum {
IconData get icon {
switch (this) {
case ProgressIndicatorEnum.wordsUsed:

View file

@ -31,8 +31,10 @@ void pLogoutAction(
final client = Matrix.of(context).client;
// before wiping out locally cached construct data, save it to the server
await MatrixState.pangeaController.putAnalytics
.sendLocalAnalyticsToAnalyticsRoom(onLogout: true);
await Matrix.of(context)
.analyticsDataService
.updateService
.sendLocalAnalyticsToAnalyticsRoom();
final redirect = client.onLoginStateChanged.stream
.where((state) => state != LoginState.loggedIn)

View file

@ -10,8 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart';
import 'package:fluffychat/pangea/common/utils/p_vguard.dart';
import 'package:fluffychat/pangea/languages/locale_provider.dart';
@ -26,8 +25,6 @@ import '../utils/firebase_analytics.dart';
class PangeaController {
///pangeaControllers
late UserController userController;
late GetAnalyticsController getAnalytics;
late PutAnalyticsController putAnalytics;
late SubscriptionController subscriptionController;
///store Services
@ -35,14 +32,13 @@ class PangeaController {
StreamSubscription? _languageSubscription;
StreamSubscription? _settingsSubscription;
StreamSubscription? _joinSpaceSubscription;
///Matrix Variables
final MatrixState matrixState;
PangeaController({required this.matrixState}) {
userController = UserController();
getAnalytics = GetAnalyticsController(this);
putAnalytics = PutAnalyticsController(this);
subscriptionController = SubscriptionController(this);
PAuthGaurd.pController = this;
_registerSubscriptions();
@ -53,7 +49,7 @@ class PangeaController {
/// because of order of execution does not matter,
/// and running them at the same times speeds them up.
void initControllers() {
_initAnalyticsControllers();
_initAnalytics();
subscriptionController.initialize();
matrixState.client.setPangeaPushRules();
TtsController.setAvailableLanguages();
@ -71,12 +67,13 @@ class PangeaController {
}
void _onLogout(BuildContext context) {
_disposeAnalyticsControllers();
userController.clear();
_languageSubscription?.cancel();
_settingsSubscription?.cancel();
_joinSpaceSubscription?.cancel();
_languageSubscription = null;
_settingsSubscription = null;
_joinSpaceSubscription = null;
GoogleAnalytics.logout();
_clearCache();
@ -109,11 +106,6 @@ class PangeaController {
GoogleAnalytics.analyticsUserUpdate(userID);
}
void _disposeAnalyticsControllers() {
putAnalytics.dispose();
getAnalytics.dispose();
}
void _registerSubscriptions() {
_languageSubscription?.cancel();
_languageSubscription =
@ -122,6 +114,11 @@ class PangeaController {
_settingsSubscription?.cancel();
_settingsSubscription = userController.settingsUpdateStream.stream
.listen((_) => matrixState.client.updateBotOptions());
_joinSpaceSubscription?.cancel();
_joinSpaceSubscription ??= matrixState.client.onSync.stream
.where(matrixState.client.isJoinSpaceSyncUpdate)
.listen((_) => matrixState.client.addAnalyticsRoomsToSpaces());
}
Future<void> _clearCache({List<String> exclude = const []}) async {
@ -146,19 +143,19 @@ class PangeaController {
await Future.wait(futures);
}
Future<void> _initAnalyticsControllers() async {
putAnalytics.initialize();
await getAnalytics.initialize();
Future<void> _initAnalytics() async {
await GetStorage.init("activity_analytics_storage");
matrixState.client.updateAnalyticsRoomJoinRules();
matrixState.client.addAnalyticsRoomsToSpaces();
}
Future<void> resetAnalytics() async {
_disposeAnalyticsControllers();
await _initAnalyticsControllers();
await _initAnalytics();
}
Future<void> _onLanguageUpdate(LanguageUpdate update) async {
final exclude = [
'analytics_storage',
'course_location_media_storage',
'course_location_storage',
'course_media_storage',
@ -184,7 +181,6 @@ class PangeaController {
'objective_list_storage',
'topic_list_storage',
'activity_plan_search_storage',
"analytics_storage",
"version_storage",
'lemma_storage',
'svg_cache',

View file

@ -4,13 +4,13 @@ import 'package:flutter/foundation.dart';
import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
@ -139,18 +139,6 @@ class ConstructIdentifier {
bool get isContentWord =>
PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false;
ConstructUses get constructUses =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
this,
) ??
ConstructUses(
lemma: lemma,
constructType: ConstructTypeEnum.morph,
category: category,
uses: [],
);
LemmaInfoRequest lemmaInfoRequest(Map<String, dynamic> messageInfo) =>
LemmaInfoRequest(
partOfSpeech: category,
@ -173,43 +161,33 @@ class ConstructIdentifier {
lemmaInfoRequest(messageInfo),
);
List<String> get userSetEmoji => userLemmaInfo.emojis ?? [];
String? get userSetEmoji => _userLemmaInfo.emojis?.firstOrNull;
UserSetLemmaInfo get userLemmaInfo {
switch (type) {
case ConstructTypeEnum.vocab:
return MatrixState.pangeaController.matrixState.client
.analyticsRoomLocal()
?.getUserSetLemmaInfo(this) ??
UserSetLemmaInfo();
case ConstructTypeEnum.morph:
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception("Morphs should not have userSetEmoji"),
data: toJson(),
);
return UserSetLemmaInfo();
}
}
UserSetLemmaInfo get _userLemmaInfo =>
MatrixState.pangeaController.matrixState.client
.analyticsRoomLocal()
?.getUserSetLemmaInfo(this) ??
UserSetLemmaInfo();
Future<void> setUserLemmaInfo(UserSetLemmaInfo newLemmaInfo) async {
final client = MatrixState.pangeaController.matrixState.client;
final l2 = MatrixState.pangeaController.userController.userL2;
if (l2 == null) return;
String get storageKey => TupleKey(lemma, type.name, category).toString();
final analyticsRoom = await client.getMyAnalyticsRoom(l2);
if (analyticsRoom == null) return;
if (userLemmaInfo == newLemmaInfo) return;
String get compositeKey => '$lemma|${type.name}'.toLowerCase();
try {
await analyticsRoom.setUserSetLemmaInfo(this, newLemmaInfo);
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
data: newLemmaInfo.toJson(),
s: s,
);
}
static ConstructIdentifier fromStorageKey(String key) {
final parts = key.split('|');
final lemma = parts[0];
final typeName = parts[1];
final category = parts[2];
final type = ConstructTypeEnum.values.firstWhereOrNull(
(e) => e.name == typeName,
) ??
ConstructTypeEnum.vocab;
return ConstructIdentifier(
lemma: lemma,
type: type,
category: category,
);
}
}

View file

@ -1,11 +1,6 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.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/constructs/construct_form.dart';
@ -15,7 +10,6 @@ import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/message_practice/message_morph_choice.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../common/constants/model_keys.dart';
import '../../lemmas/lemma.dart';
@ -175,18 +169,6 @@ class PangeaToken {
return null;
}
ConstructUses get vocabConstruct =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
vocabConstructID,
) ??
ConstructUses(
lemma: lemma.text,
constructType: ConstructTypeEnum.vocab,
category: pos,
uses: [],
);
ConstructIdentifier? morphIdByFeature(MorphFeaturesEnum feature) {
final tag = getMorphTag(feature);
if (tag == null) return null;
@ -197,40 +179,6 @@ class PangeaToken {
);
}
/// lastUsed by activity type, construct and form
DateTime? _lastUsedByActivityType(
ActivityTypeEnum a,
MorphFeaturesEnum? feature,
) {
if (a == ActivityTypeEnum.morphId && feature == null) {
debugger(when: kDebugMode);
return null;
}
final ConstructIdentifier? cId = a == ActivityTypeEnum.morphId
? morphIdByFeature(feature!)
: vocabConstructID;
if (cId == null) return null;
final correctUseTimestamps = cId.constructUses.uses
.where((u) => u.form == text.content)
.map((u) => u.timeStamp)
.toList();
if (correctUseTimestamps.isEmpty) return null;
// return the most recent timestamp
return correctUseTimestamps.reduce((a, b) => a.isAfter(b) ? a : b);
}
/// daysSinceLastUse by activity type
/// returns 1000 if there is no last use
int daysSinceLastUseByType(ActivityTypeEnum a, MorphFeaturesEnum? feature) {
final lastUsed = _lastUsedByActivityType(a, feature);
if (lastUsed == null) return 20;
return DateTime.now().difference(lastUsed).inDays;
}
ConstructIdentifier get vocabConstructID => ConstructIdentifier(
lemma: lemma.text,
type: ConstructTypeEnum.vocab,
@ -268,15 +216,6 @@ class PangeaToken {
);
}).toList();
/// [0,infinity) - a higher number means higher priority
int activityPriorityScore(
ActivityTypeEnum a,
MorphFeaturesEnum? morphFeature,
) {
return daysSinceLastUseByType(a, morphFeature) *
(vocabConstructID.isContentWord ? 10 : 9);
}
bool eligibleForPractice(ActivityTypeEnum activityType) {
switch (activityType) {
case ActivityTypeEnum.emoji:

View file

@ -7,7 +7,6 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
@ -25,12 +24,10 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart
import 'package:fluffychat/pangea/choreographer/igc/igc_request_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/spaces/space_constants.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';

View file

@ -297,46 +297,59 @@ extension EventsRoomExtension on Room {
Future<List<Event>> getRoomAnalyticsEvents({
String? userID,
int? count,
DateTime? since,
}) async {
userID ??= client.userID;
if (userID == null) return [];
GetRoomEventsResponse resp = await client.getRoomEvents(
id,
Direction.b,
limit: count ?? 100,
filter: jsonEncode(
StateFilter(
final timeline = await getTimeline();
int numSearches = 0;
while (numSearches < 10 && timeline.canRequestFuture) {
await timeline.requestFuture(
historyCount: 100,
filter: StateFilter(
types: [
PangeaEventTypes.construct,
],
senders: [userID],
),
),
);
int numSearches = 0;
while (numSearches < 10 && resp.end != null) {
if (count != null && resp.chunk.length <= count) break;
final nextResp = await client.getRoomEvents(
id,
Direction.b,
limit: count ?? 100,
filter: jsonEncode(
StateFilter(
types: [
PangeaEventTypes.construct,
],
senders: [userID],
),
),
from: resp.end,
);
nextResp.chunk.addAll(resp.chunk);
resp = nextResp;
numSearches += 1;
}
return resp.chunk.map((e) => Event.fromMatrixEvent(e, this)).toList();
while (numSearches < 10 &&
timeline.canRequestHistory &&
(count == null || timeline.events.length < count) &&
(since == null ||
timeline.chunk.events.first.originServerTs.isAfter(since))) {
await timeline.requestHistory(
historyCount: 100,
filter: StateFilter(
types: [
PangeaEventTypes.construct,
],
senders: [userID],
),
);
numSearches += 1;
}
final events = timeline.chunk.events
.where(
(e) => e.type == PangeaEventTypes.construct && e.senderId == userID,
)
.map((e) => Event.fromMatrixEvent(e, this));
if (count != null) {
return events.take(count).toList();
}
if (since != null) {
return events.where((e) => e.originServerTs.isAfter(since)).toList();
}
return events.toList();
}
Future<List<Event>> getAllEvents({String? since}) async {

View file

@ -5,8 +5,7 @@ import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
import 'package:fluffychat/pangea/common/widgets/shimmer_background.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_meaning_builder.dart';
@ -37,29 +36,8 @@ class LemmaHighlightEmojiRow extends StatefulWidget {
State<LemmaHighlightEmojiRow> createState() => LemmaHighlightEmojiRowState();
}
class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow> {
late StreamSubscription<AnalyticsStreamUpdate> _analyticsSubscription;
@override
void initState() {
super.initState();
_analyticsSubscription = MatrixState
.pangeaController.getAnalytics.analyticsStream.stream
.listen(_onAnalyticsUpdate);
}
@override
void dispose() {
_analyticsSubscription.cancel();
super.dispose();
}
void _onAnalyticsUpdate(AnalyticsStreamUpdate update) {
if (update.targetID != null) {
OverlayUtil.showPointsGained(update.targetID!, update.points, context);
}
}
class LemmaHighlightEmojiRowState extends State<LemmaHighlightEmojiRow>
with AnalyticsUpdater {
@override
Widget build(BuildContext context) {
return LemmaMeaningBuilder(
@ -176,9 +154,6 @@ class EmojiChoiceItemState extends State<EmojiChoiceItem> {
});
}
LayerLink get layerLink =>
MatrixState.pAnyState.layerLinkAndKey(widget.transformTargetId).link;
@override
Widget build(BuildContext context) {
return HoverBuilder(
@ -192,8 +167,13 @@ class EmojiChoiceItemState extends State<EmojiChoiceItem> {
? Colors.white
: Theme.of(context).colorScheme.primary,
child: CompositedTransformTarget(
link: layerLink,
link: MatrixState.pAnyState
.layerLinkAndKey(widget.transformTargetId)
.link,
child: AnimatedContainer(
key: MatrixState.pAnyState
.layerLinkAndKey(widget.transformTargetId)
.key,
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(

View file

@ -42,5 +42,6 @@ class UserSetLemmaInfo {
meaning == other.meaning;
@override
int get hashCode => meaning.hashCode ^ Object.hashAll(emojis ?? []);
int get hashCode =>
meaning.hashCode ^ const ListEquality().hash(emojis ?? []);
}

View file

@ -29,11 +29,10 @@ class EmojiActivityGenerator {
final List<String> usedEmojis = [];
for (final token in req.targetTokens) {
final List<String> userSavedEmojis = token.vocabConstructID.userSetEmoji;
if (userSavedEmojis.isNotEmpty &&
!usedEmojis.contains(userSavedEmojis.first)) {
matchInfo[token.vocabForm] = [userSavedEmojis.first];
usedEmojis.add(userSavedEmojis.first);
final userSavedEmoji = token.vocabConstructID.userSetEmoji;
if (userSavedEmoji != null && !usedEmojis.contains(userSavedEmoji)) {
matchInfo[token.vocabForm] = [userSavedEmoji];
usedEmojis.add(userSavedEmoji);
} else {
missingEmojis.add(token);
}

View file

@ -36,12 +36,12 @@ class LemmaActivityGenerator {
static Future<Set<String>> _lemmaActivityDistractors(
PangeaToken token,
) async {
final List<String> lemmas = MatrixState
.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.map((c) => c.lemma)
.toSet()
.toList();
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab);
final List<String> lemmas =
constructs.values.map((c) => c.lemma).toSet().toList();
// Offload computation to an isolate
final Map<String, int> distances =

View file

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -34,11 +35,11 @@ class _PracticeSelectionCacheEntry {
class PracticeSelectionRepo {
static final GetStorage _storage = GetStorage('practice_selection_cache');
static PracticeSelection? get(
static Future<PracticeSelection?> get(
String eventId,
String messageLanguage,
List<PangeaToken> tokens,
) {
) async {
final userL2 = MatrixState.pangeaController.userController.userL2;
if (userL2?.langCodeShort != messageLanguage.split("-").first) {
return null;
@ -47,7 +48,7 @@ class PracticeSelectionRepo {
final cached = _getCached(eventId);
if (cached != null) return cached;
final newEntry = _fetch(
final newEntry = await _fetch(
tokens: tokens,
langCode: messageLanguage,
);
@ -56,10 +57,10 @@ class PracticeSelectionRepo {
return newEntry;
}
static PracticeSelection _fetch({
static Future<PracticeSelection> _fetch({
required List<PangeaToken> tokens,
required String langCode,
}) {
}) async {
if (langCode.split("-")[0] !=
MatrixState.pangeaController.userController.userL2?.langCodeShort) {
return PracticeSelection({});
@ -69,7 +70,7 @@ class PracticeSelectionRepo {
if (eligibleTokens.isEmpty) {
return PracticeSelection({});
}
final queue = _fillActivityQueue(eligibleTokens);
final queue = await _fillActivityQueue(eligibleTokens);
final selection = PracticeSelection(queue);
return selection;
}
@ -116,12 +117,12 @@ class PracticeSelectionRepo {
_storage.write(eventId, cachedEntry.toJson());
}
static Map<ActivityTypeEnum, List<PracticeTarget>> _fillActivityQueue(
static Future<Map<ActivityTypeEnum, List<PracticeTarget>>> _fillActivityQueue(
List<PangeaToken> tokens,
) {
) async {
final queue = <ActivityTypeEnum, List<PracticeTarget>>{};
for (final type in ActivityTypeEnum.practiceTypes) {
queue[type] = _buildActivity(type, tokens);
queue[type] = await _buildActivity(type, tokens);
}
return queue;
}
@ -129,26 +130,18 @@ class PracticeSelectionRepo {
static int _sortTokens(
PangeaToken a,
PangeaToken b,
ActivityTypeEnum activityType,
) {
final bScore = b.activityPriorityScore(activityType, null);
final aScore = a.activityPriorityScore(activityType, null);
return bScore.compareTo(aScore);
}
int aScore,
int bScore,
) =>
bScore.compareTo(aScore);
static int _sortMorphTargets(PracticeTarget a, PracticeTarget b) {
final bScore = b.tokens.first.activityPriorityScore(
ActivityTypeEnum.morphId,
b.morphFeature!,
);
final aScore = a.tokens.first.activityPriorityScore(
ActivityTypeEnum.morphId,
a.morphFeature!,
);
return bScore.compareTo(aScore);
}
static int _sortMorphTargets(
PracticeTarget a,
PracticeTarget b,
int aScore,
int bScore,
) =>
bScore.compareTo(aScore);
static List<PracticeTarget> _tokenToMorphTargets(PangeaToken t) {
return t.morphsBasicallyEligibleForPracticeByPriority
@ -162,10 +155,10 @@ class PracticeSelectionRepo {
.toList();
}
static List<PracticeTarget> _buildActivity(
static Future<List<PracticeTarget>> _buildActivity(
ActivityTypeEnum activityType,
List<PangeaToken> tokens,
) {
) async {
if (activityType == ActivityTypeEnum.morphId) {
return _buildMorphActivity(tokens);
}
@ -184,7 +177,12 @@ class PracticeSelectionRepo {
return [];
}
practiceTokens.sort((a, b) => _sortTokens(a, b, activityType));
final scores = await _fetchPriorityScores(
practiceTokens,
activityType,
);
practiceTokens.sort((a, b) => _sortTokens(a, b, scores[a]!, scores[b]!));
practiceTokens = practiceTokens.take(8).toList();
practiceTokens.shuffle();
@ -196,10 +194,23 @@ class PracticeSelectionRepo {
];
}
static List<PracticeTarget> _buildMorphActivity(List<PangeaToken> tokens) {
static Future<List<PracticeTarget>> _buildMorphActivity(
List<PangeaToken> tokens,
) async {
final List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
final candidates = practiceTokens.expand(_tokenToMorphTargets).toList();
candidates.sort(_sortMorphTargets);
final scores = await _fetchPriorityScores(
practiceTokens,
ActivityTypeEnum.morphId,
);
candidates.sort(
(a, b) => _sortMorphTargets(
a,
b,
scores[a.tokens.first]!,
scores[b.tokens.first]!,
),
);
final seenTexts = <String>{};
final seenLemmas = <String>{};
@ -210,4 +221,38 @@ class PracticeSelectionRepo {
);
return candidates.take(PracticeSelection.maxQueueLength).toList();
}
static Future<Map<PangeaToken, int>> _fetchPriorityScores(
List<PangeaToken> tokens,
ActivityTypeEnum activityType,
) async {
final scores = <PangeaToken, int>{};
for (final token in tokens) {
scores[token] = 0;
}
final ids = tokens.map((t) => t.vocabConstructID).toList();
final idMap = {
for (final token in tokens) token: token.vocabConstructID,
};
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getConstructUses(ids);
for (final token in tokens) {
final construct = constructs[idMap[token]];
final lastUsed = construct?.uses.firstWhereOrNull(
(u) => activityType.associatedUseTypes.contains(u.useType),
);
final daysSinceLastUsed = lastUsed == null
? 20
: DateTime.now().difference(lastUsed.timeStamp).inDays;
scores[token] =
daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 9);
}
return scores;
}
}

View file

@ -9,9 +9,9 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/download/download_file_util.dart';
@ -170,19 +170,11 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
if (mounted) setState(() => _downloadStatuses[userID] = -1);
return SpaceAnalyticsSummaryModel.emptyModel(userID);
}
final List<OneConstructUse> uses = [];
for (final event in constructEvents) {
uses.addAll(event.content.uses);
}
final constructs = ConstructListModel(uses: uses);
summary = SpaceAnalyticsSummaryModel.fromConstructListModel(
summary = SpaceAnalyticsSummaryModel.fromEvents(
userID,
constructs,
0,
getCopy,
context,
constructEvents,
analyticsRoom.blockedConstructs,
analyticsRoom.archivedActivitiesCount,
);
if (mounted) setState(() => _downloadStatuses[userID] = 2);
} catch (e, s) {

View file

@ -6,13 +6,12 @@ import 'package:intl/intl.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/saved_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/languages/language_model.dart';
import 'package:fluffychat/pangea/languages/p_language_store.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_download_model.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_requests_repo.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart';
@ -23,88 +22,6 @@ import 'package:fluffychat/pangea/user/analytics_profile_model.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
// enum DownloadStatus {
// loading,
// available,
// unavailable,
// notFound;
// }
// enum RequestStatus {
// available,
// unrequested,
// requested,
// notFound;
// static RequestStatus? fromString(String value) {
// switch (value) {
// case 'available':
// return RequestStatus.available;
// case 'unrequested':
// return RequestStatus.unrequested;
// case 'requested':
// return RequestStatus.requested;
// case 'notFound':
// return RequestStatus.notFound;
// default:
// return null;
// }
// }
// IconData get icon {
// switch (this) {
// case RequestStatus.available:
// return Icons.check_circle;
// case RequestStatus.unrequested:
// return Symbols.approval_delegation;
// case RequestStatus.requested:
// return Icons.mark_email_read_outlined;
// case RequestStatus.notFound:
// return Symbols.approval_delegation;
// }
// }
// String label(BuildContext context) {
// final l10n = L10n.of(context);
// switch (this) {
// case RequestStatus.available:
// return l10n.available;
// case RequestStatus.unrequested:
// return l10n.request;
// case RequestStatus.requested:
// return l10n.pending;
// case RequestStatus.notFound:
// return l10n.inactive;
// }
// }
// Color backgroundColor(BuildContext context) {
// final theme = Theme.of(context);
// switch (this) {
// case RequestStatus.available:
// case RequestStatus.unrequested:
// return theme.colorScheme.primaryContainer;
// case RequestStatus.notFound:
// case RequestStatus.requested:
// return theme.disabledColor;
// }
// }
// bool get showButton => this != RequestStatus.available;
// bool get enabled => this == RequestStatus.unrequested;
// }
// class AnalyticsDownload {
// DownloadStatus status;
// SpaceAnalyticsSummaryModel? summary;
// AnalyticsDownload({
// required this.status,
// this.summary,
// });
// }
class SpaceAnalytics extends StatefulWidget {
final String roomId;
const SpaceAnalytics({super.key, required this.roomId});
@ -345,24 +262,11 @@ class SpaceAnalyticsState extends State<SpaceAnalytics> {
summary: SpaceAnalyticsSummaryModel.emptyModel(userID),
);
} else {
final List<OneConstructUse> uses = [];
for (final event in constructEvents) {
uses.addAll(event.content.uses);
}
final constructs = ConstructListModel(uses: uses);
summary = SpaceAnalyticsSummaryModel.fromConstructListModel(
summary = SpaceAnalyticsSummaryModel.fromEvents(
userID,
constructs,
analyticsRoom.activityRoomIds.length,
(use) =>
getGrammarCopy(
category: use.category,
lemma: use.lemma,
context: context,
) ??
use.lemma,
context,
constructEvents,
analyticsRoom.blockedConstructs,
analyticsRoom.archivedActivitiesCount,
);
downloads[user] = AnalyticsDownload(

View file

@ -24,7 +24,9 @@ import 'package:fluffychat/widgets/matrix.dart';
class PracticeController with ChangeNotifier {
final PangeaMessageEvent pangeaMessageEvent;
PracticeController(this.pangeaMessageEvent);
PracticeController(this.pangeaMessageEvent) {
_fetchPracticeSelection();
}
PracticeActivityModel? _activity;
@ -35,14 +37,7 @@ class PracticeController with ChangeNotifier {
PracticeActivityModel? get activity => _activity;
PracticeSelection? get practiceSelection =>
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null
? PracticeSelectionRepo.get(
pangeaMessageEvent.eventId,
pangeaMessageEvent.messageDisplayLangCode,
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
)
: null;
PracticeSelection? practiceSelection;
bool get isTotallyDone =>
isPracticeActivityDone(ActivityTypeEnum.emoji) &&
@ -58,7 +53,7 @@ class PracticeController with ChangeNotifier {
final target = practiceTargetForToken(token);
if (MessagePracticeMode.wordEmoji == practiceMode) {
if (token.vocabConstructID.userSetEmoji.firstOrNull != null) {
if (token.vocabConstructID.userSetEmoji != null) {
return false;
}
// Keep open even when completed to show emoji
@ -90,6 +85,15 @@ class PracticeController with ChangeNotifier {
!_activity!.practiceTarget.hasAnyCorrectChoices;
}
Future<void> _fetchPracticeSelection() async {
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) return;
practiceSelection = await PracticeSelectionRepo.get(
pangeaMessageEvent.eventId,
pangeaMessageEvent.messageDisplayLangCode,
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
);
}
Future<Result<PracticeActivityModel>> fetchActivityModel(
PracticeTarget target,
) async {
@ -158,6 +162,9 @@ class PracticeController with ChangeNotifier {
final targetId =
"message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}";
final updateService = MatrixState
.pangeaController.matrixState.analyticsDataService.updateService;
// we don't take off points for incorrect emoji matches
if (_activity!.activityType != ActivityTypeEnum.emoji || isCorrect) {
final constructUseType = _activity!.practiceTarget.record.responses.last
@ -180,28 +187,24 @@ class PracticeController with ChangeNotifier {
),
];
MatrixState.pangeaController.putAnalytics.addAnalytics(
updateService.addAnalytics(
targetId,
constructs,
eventId: pangeaMessageEvent.eventId,
roomId: pangeaMessageEvent.room.id,
targetId: targetId,
);
}
if (isCorrect) {
if (_activity!.activityType == ActivityTypeEnum.emoji) {
choice.form.cId.setUserLemmaInfo(
choice.form.cId.userLemmaInfo.copyWith(
emojis: [choice.choiceContent],
),
updateService.setLemmaInfo(
choice.form.cId,
emoji: choice.choiceContent,
);
}
if (_activity!.activityType == ActivityTypeEnum.wordMeaning) {
choice.form.cId.setUserLemmaInfo(
choice.form.cId.userLemmaInfo.copyWith(
meaning: choice.choiceContent,
),
updateService.setLemmaInfo(
choice.form.cId,
meaning: choice.choiceContent,
);
}
}

View file

@ -263,7 +263,7 @@ class _NoActivityContentButton extends StatelessWidget {
(res) => res.cId == token.vocabConstructID && res.isCorrect,
)
?.text ??
token.vocabConstructID.userSetEmoji.firstOrNull ??
token.vocabConstructID.userSetEmoji ??
'';
return Text(
displayEmoji,

View file

@ -10,6 +10,7 @@ import 'package:matrix/matrix.dart' hide Result;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.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';
@ -55,7 +56,7 @@ class MessageSelectionOverlay extends StatefulWidget {
}
class MessageOverlayController extends State<MessageSelectionOverlay>
with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin, AnalyticsUpdater {
Event get event => widget._event;
PangeaTokenText? _selectedSpan;
@ -234,12 +235,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
xp: ConstructUseTypeEnum.click.pointValue,
),
];
MatrixState.pangeaController.putAnalytics.addAnalytics(
constructs,
eventId: event.eventId,
roomId: event.room.id,
targetId: "word-zoom-card-${token.text.uniqueKey}",
);
addAnalytics(constructs, "word-zoom-card-${token.text.uniqueKey}");
}
}
}

View file

@ -8,7 +8,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
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/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/speech_to_text/speech_to_text_response_model.dart';
@ -89,8 +88,6 @@ class SelectModeController with LemmaEmojiSetter {
_sttTranslationLoader = _STTTranslationLoader(messageEvent);
ValueNotifier<SelectMode?> selectedMode = ValueNotifier<SelectMode?>(null);
ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier =
ValueNotifier<(ConstructIdentifier, String)?>(null);
final StreamController contentChangedStream = StreamController.broadcast();
@ -101,7 +98,6 @@ class SelectModeController with LemmaEmojiSetter {
void dispose() {
selectedMode.dispose();
constructEmojiNotifier.dispose();
playTokenNotifier.dispose();
_transcriptLoader.dispose();
_translationLoader.dispose();
@ -198,12 +194,6 @@ class SelectModeController with LemmaEmojiSetter {
selectedMode.value = mode;
}
void setTokenEmoji(
ConstructIdentifier constructId,
String emoji,
) =>
constructEmojiNotifier.value = (constructId, emoji);
void setPlayingToken(PangeaTokenText? token) =>
playTokenNotifier.value = (token, !playTokenNotifier.value.$2);

View file

@ -2,16 +2,15 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/lemma_emoji_setter_mixin.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance/select_mode_buttons.dart';
import 'package:fluffychat/widgets/matrix.dart';
class TokenEmojiButton extends StatefulWidget {
final ValueNotifier<SelectMode?> selectModeNotifier;
final ValueNotifier<(ConstructIdentifier, String)?> constructEmojiNotifier;
final VoidCallback onTap;
class TokenEmojiButton extends StatelessWidget with LemmaEmojiSetter {
static const double _buttonSize = 24.0;
final ValueNotifier<SelectMode?> selectModeNotifier;
final VoidCallback onTap;
final PangeaToken? token;
final String? targetId;
final bool enabled;
@ -20,7 +19,6 @@ class TokenEmojiButton extends StatefulWidget {
const TokenEmojiButton({
super.key,
required this.selectModeNotifier,
required this.constructEmojiNotifier,
required this.onTap,
required this.textColor,
this.token,
@ -28,127 +26,94 @@ class TokenEmojiButton extends StatefulWidget {
this.enabled = true,
});
@override
State<TokenEmojiButton> createState() => TokenEmojiButtonState();
}
class TokenEmojiButtonState extends State<TokenEmojiButton>
with TickerProviderStateMixin, LemmaEmojiSetter {
final double buttonSize = 24.0;
SelectMode? _prevMode;
AnimationController? _controller;
Animation<double>? _sizeAnimation;
String? _emoji;
@override
void initState() {
super.initState();
_emoji = widget.token?.vocabConstructID.userSetEmoji.firstOrNull;
_initAnimation();
_prevMode = widget.selectModeNotifier.value;
widget.selectModeNotifier.addListener(_onUpdateSelectMode);
widget.constructEmojiNotifier.addListener(_onUpdateEmoji);
}
@override
void dispose() {
_controller?.dispose();
widget.selectModeNotifier.removeListener(_onUpdateSelectMode);
widget.constructEmojiNotifier.removeListener(_onUpdateEmoji);
super.dispose();
}
void _initAnimation() {
if (MatrixState.pangeaController.subscriptionController.isSubscribed ==
false) {
return;
}
_controller = AnimationController(
vsync: this,
duration: FluffyThemes.animationDuration,
);
_sizeAnimation = Tween<double>(
begin: 0,
end: buttonSize,
).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut));
}
void _onUpdateSelectMode() {
final mode = widget.selectModeNotifier.value;
if (_prevMode != SelectMode.emoji && mode == SelectMode.emoji) {
_controller?.forward();
} else if (_prevMode == SelectMode.emoji && mode != SelectMode.emoji) {
_controller?.reverse();
}
_prevMode = mode;
}
void _onUpdateEmoji() {
final value = widget.constructEmojiNotifier.value;
if (value == null) return;
final constructId = value.$1;
final emoji = value.$2;
if (mounted && constructId == widget.token?.vocabConstructID) {
setState(() => _emoji = emoji);
}
}
bool get _canShow =>
MatrixState.pangeaController.subscriptionController.isSubscribed != false;
@override
Widget build(BuildContext context) {
if (_sizeAnimation == null) {
return const SizedBox.shrink();
}
if (!_canShow) return const SizedBox.shrink();
final child = widget.enabled
? Text(
_emoji ?? "-",
style: TextStyle(fontSize: buttonSize - 8.0).copyWith(
color: widget.textColor,
),
textScaler: TextScaler.noScaling,
)
: null;
Widget content = ValueListenableBuilder<SelectMode?>(
valueListenable: selectModeNotifier,
builder: (context, mode, _) {
final visible = mode == SelectMode.emoji;
final content = ValueListenableBuilder(
valueListenable: widget.selectModeNotifier,
builder: (context, mode, __) {
return mode == SelectMode.emoji
? AnimatedBuilder(
key: widget.targetId != null
? MatrixState.pAnyState
.layerLinkAndKey(widget.targetId!)
.key
: null,
animation: _sizeAnimation!,
child: child,
builder: (context, child) {
return InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(99.0),
child: Container(
height: _sizeAnimation!.value,
width: widget.enabled ? _sizeAnimation!.value : 0,
alignment: Alignment.center,
child: child,
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: Curves.easeOut,
alignment: Alignment.center,
child: visible
? InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(99),
child: SizedBox(
width: _buttonSize,
height: _buttonSize,
child: Center(
child: _EmojiText(
token: token,
enabled: enabled,
textColor: textColor,
fontSize: _buttonSize - 8,
),
),
);
},
)
: const SizedBox();
),
)
: const SizedBox.shrink(),
);
},
);
return widget.targetId != null
? CompositedTransformTarget(
link: MatrixState.pAnyState.layerLinkAndKey(widget.targetId!).link,
child: content,
)
: content;
if (targetId != null) {
final layer = MatrixState.pAnyState.layerLinkAndKey(targetId!);
content = CompositedTransformTarget(
link: layer.link,
child: KeyedSubtree(
key: layer.key,
child: content,
),
);
}
return content;
}
}
class _EmojiText extends StatelessWidget {
final PangeaToken? token;
final bool enabled;
final Color textColor;
final double fontSize;
const _EmojiText({
required this.token,
required this.enabled,
required this.textColor,
required this.fontSize,
});
@override
Widget build(BuildContext context) {
if (!enabled || token == null) return const SizedBox.shrink();
return StreamBuilder(
stream: Matrix.of(context)
.analyticsDataService
.updateDispatcher
.lemmaUpdateStream(token!.vocabConstructID),
builder: (context, snapshot) {
final emoji = snapshot.data?.emojis?.firstOrNull ??
token!.vocabConstructID.userSetEmoji;
return Text(
emoji ?? "-",
style: TextStyle(
fontSize: fontSize,
color: textColor,
),
textScaler: TextScaler.noScaling,
);
},
);
}
}

View file

@ -98,11 +98,17 @@ class TokensUtil {
}
final List<PangeaTokenText> newTokens = [];
final analyticsService =
MatrixState.pangeaController.matrixState.analyticsDataService;
for (final token in tokens) {
if (!token.lemma.saveVocab || !token.vocabConstructID.isContentWord) {
continue;
}
if (token.vocabConstruct.uses.isNotEmpty) continue;
if (analyticsService.hasUsedConstruct(token.vocabConstructID)) {
continue;
}
if (newTokens.any((t) => t == token.text)) continue;
newTokens.add(token.text);

View file

@ -9,54 +9,22 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_highlight_emoji_row.dart';
import 'package:fluffychat/widgets/matrix.dart';
class LemmaReactionPicker extends StatefulWidget {
class LemmaReactionPicker extends StatelessWidget with LemmaEmojiSetter {
final Event? event;
final ConstructIdentifier constructId;
final Function(String)? onSetEmoji;
final String langCode;
const LemmaReactionPicker({
super.key,
required this.constructId,
required this.onSetEmoji,
required this.langCode,
this.event,
});
@override
State<LemmaReactionPicker> createState() => LemmaReactionPickerState();
}
class LemmaReactionPickerState extends State<LemmaReactionPicker>
with LemmaEmojiSetter {
String? _selectedEmoji;
@override
void initState() {
super.initState();
_setInitialEmoji();
}
@override
void didUpdateWidget(covariant LemmaReactionPicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructId != widget.constructId) {
_setInitialEmoji();
}
}
void _setInitialEmoji() {
setState(
() {
_selectedEmoji = widget.constructId.userLemmaInfo.emojis?.firstOrNull;
},
);
}
Event? _sentReaction(String emoji) {
final userSentEmojis = widget.event!
Event? _sentReaction(String emoji, BuildContext context) {
final userSentEmojis = event!
.aggregatedEvents(
widget.event!.room.timeline!,
event!.room.timeline!,
RelationshipTypes.reaction,
)
.where(
@ -68,33 +36,27 @@ class LemmaReactionPickerState extends State<LemmaReactionPicker>
);
}
Future<void> _setEmoji(String emoji) async {
setState(() => _selectedEmoji = emoji);
widget.onSetEmoji?.call(emoji);
Future<void> _setEmoji(String emoji, BuildContext context) async {
await setLemmaEmoji(
widget.constructId,
constructId,
emoji,
"emoji-choice-item-$emoji-${widget.constructId.lemma}",
"emoji-choice-item-$emoji-${constructId.lemma}",
);
if (mounted) {
showLemmaEmojiSnackbar(context, widget.constructId, emoji);
}
showLemmaEmojiSnackbar(context, constructId, emoji);
}
Future<void> _sendOrRedactReaction(String emoji) async {
if (widget.event?.room.timeline == null) return;
Future<void> _sendOrRedactReaction(String emoji, BuildContext context) async {
if (event?.room.timeline == null) return;
try {
final reactionEvent = _sentReaction(emoji);
final reactionEvent = _sentReaction(emoji, context);
if (reactionEvent != null) {
await reactionEvent.redactEvent();
return;
}
await widget.event!.room.sendReaction(
widget.event!.eventId,
await event!.room.sendReaction(
event!.eventId,
emoji,
);
} catch (e, s) {
@ -103,7 +65,7 @@ class LemmaReactionPickerState extends State<LemmaReactionPicker>
s: s,
data: {
'emoji': emoji,
'eventId': widget.event?.eventId,
'eventId': event?.eventId,
},
);
}
@ -111,22 +73,35 @@ class LemmaReactionPickerState extends State<LemmaReactionPicker>
@override
Widget build(BuildContext context) {
return LemmaHighlightEmojiRow(
cId: widget.constructId,
langCode: widget.langCode,
onEmojiSelected: (emoji) => emoji != _selectedEmoji
? _setEmoji(emoji)
: _sendOrRedactReaction(emoji),
emoji: _selectedEmoji,
messageInfo: widget.event?.content ?? {},
selectedEmojiBadge: widget.event != null &&
_selectedEmoji != null &&
_sentReaction(_selectedEmoji!) == null
? const Icon(
Icons.add_reaction,
size: 12.0,
)
: null,
final stream = Matrix.of(context)
.analyticsDataService
.updateDispatcher
.lemmaUpdateStream(constructId);
return StreamBuilder(
stream: stream,
builder: (context, snapshot) {
final selectedEmoji =
snapshot.data?.emojis?.firstOrNull ?? constructId.userSetEmoji;
return LemmaHighlightEmojiRow(
cId: constructId,
langCode: langCode,
onEmojiSelected: (emoji) => emoji != selectedEmoji
? _setEmoji(emoji, context)
: _sendOrRedactReaction(emoji, context),
emoji: selectedEmoji,
messageInfo: event?.content ?? {},
selectedEmojiBadge: event != null &&
selectedEmoji != null &&
_sentReaction(selectedEmoji, context) == null
? const Icon(
Icons.add_reaction,
size: 12.0,
)
: null,
);
},
);
}
}

View file

@ -51,10 +51,6 @@ class ReadingAssistanceContent extends StatelessWidget {
onClose: () => overlayController.updateSelectedSpan(null),
langCode: overlayController.pangeaMessageEvent.messageDisplayLangCode,
onDismissNewWordOverlay: () => overlayController.setState(() {}),
setEmoji: (emoji) => overlayController.selectModeController.setTokenEmoji(
overlayController.selectedToken!.vocabConstructID,
emoji,
),
onFlagTokenInfo: (LemmaInfoResponse lemmaInfo, String phonetics) {
if (selectedTokenIndex < 0) return;
final requestData = TokenInfoFeedbackRequestData(

View file

@ -29,14 +29,12 @@ class WordZoomWidget extends StatelessWidget {
final VoidCallback? onDismissNewWordOverlay;
final Function(LemmaInfoResponse, String)? onFlagTokenInfo;
final Function(String)? setEmoji;
const WordZoomWidget({
super.key,
required this.token,
required this.construct,
required this.langCode,
this.setEmoji,
this.onClose,
this.wordIsNew = false,
this.event,
@ -151,7 +149,6 @@ class WordZoomWidget extends StatelessWidget {
LemmaReactionPicker(
constructId: construct,
langCode: langCode,
onSetEmoji: setEmoji,
event: event,
),
LemmaMeaningDisplay(

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart' as matrix;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
@ -187,25 +186,11 @@ class UserController {
// so it waits for analytics to finish initializing. Analytics waits for user controller to
// finish initializing, so this would cause a deadlock.
if (analyticsProfile!.isEmpty) {
MatrixState.pangeaController.getAnalytics.initCompleter.future
.timeout(const Duration(seconds: 10))
.then((_) {
updateAnalyticsProfile(
level: MatrixState
.pangeaController.getAnalytics.constructListModel.level,
);
}).catchError((e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"publicProfile": analyticsProfile?.toJson(),
"userId": client.userID,
},
level:
e is TimeoutException ? SentryLevel.warning : SentryLevel.error,
);
});
final analyticsService =
MatrixState.pangeaController.matrixState.analyticsDataService;
final data = await analyticsService.derivedData;
updateAnalyticsProfile(level: data.level);
}
}

View file

@ -172,17 +172,6 @@ abstract class ClientManager {
onSoftLogout:
enableSoftLogout ? (client) => client.refreshAccessToken() : null,
// #Pangea
syncFilter: Filter(
room: RoomFilter(
state: StateFilter(lazyLoadMembers: true),
timeline: StateFilter(
notTypes: [
PangeaEventTypes.construct,
PangeaEventTypes.summaryAnalytics,
],
),
),
),
shouldReplaceRoomLastEvent: (_, event) {
return event.content.tryGet(ModelKey.transcription) == null &&
!event.type.startsWith("p.") &&

View file

@ -20,6 +20,7 @@ import 'package:universal_html/html.dart' as html;
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -74,6 +75,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
static late PangeaController pangeaController;
static PangeaAnyState pAnyState = PangeaAnyState();
late StreamSubscription? _uriListener;
final Map<String, AnalyticsDataService> _analyticsServices = {};
// Pangea#
SharedPreferences get store => widget.store;
@ -99,6 +102,18 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
return widget.clients[_activeClient];
}
// #Pangea
AnalyticsDataService get analyticsDataService {
if (_analyticsServices[client.clientName] == null) {
Logs().w(
'Tried to access AnalyticsDataService for client ${client.clientName}, but it does not exist.',
);
_analyticsServices[client.clientName] = AnalyticsDataService(client);
}
return _analyticsServices[client.clientName]!;
}
// Pangea#
VoipPlugin? voipPlugin;
bool get isMultiAccount => widget.clients.length > 1;
@ -287,8 +302,11 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
Future<void> _setLanguageListener() async {
await pangeaController.userController.initialize();
_languageListener?.cancel();
_languageListener = pangeaController.userController.languageStream.stream
.listen((_) => _setAppLanguage());
_languageListener =
pangeaController.userController.languageStream.stream.listen((update) {
_setAppLanguage();
analyticsDataService.updateService.onUpdateLanguages(update);
});
}
void _setAppLanguage() {
@ -413,6 +431,9 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
c.onNotification.stream.listen(showLocalNotification);
});
}
// #Pangea
_analyticsServices[name] ??= AnalyticsDataService(c);
// Pangea#
}
void _cancelSubs(String name) {
@ -427,6 +448,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
// #Pangea
onUiaRequest[name]?.cancel();
onUiaRequest.remove(name);
_analyticsServices[name]?.dispose();
_analyticsServices.remove(name);
// Pangea#
}

View file

@ -1555,10 +1555,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mgrs_dart:
dependency: transitive
description:
@ -2480,26 +2480,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.2"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.11"
version: "0.6.12"
timezone:
dependency: transitive
description: