feat: analytics database
This commit is contained in:
parent
b363021504
commit
d8caf8e481
77 changed files with 3818 additions and 3408 deletions
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
456
lib/pangea/analytics_data/analytics_data_service.dart
Normal file
456
lib/pangea/analytics_data/analytics_data_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
698
lib/pangea/analytics_data/analytics_database.dart
Normal file
698
lib/pangea/analytics_data/analytics_database.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
95
lib/pangea/analytics_data/analytics_database_builder.dart
Normal file
95
lib/pangea/analytics_data/analytics_database_builder.dart
Normal 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');
|
||||
}
|
||||
85
lib/pangea/analytics_data/analytics_sync_controller.dart
Normal file
85
lib/pangea/analytics_data/analytics_sync_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
140
lib/pangea/analytics_data/analytics_update_dispatcher.dart
Normal file
140
lib/pangea/analytics_data/analytics_update_dispatcher.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
25
lib/pangea/analytics_data/analytics_update_events.dart
Normal file
25
lib/pangea/analytics_data/analytics_update_events.dart
Normal 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);
|
||||
}
|
||||
164
lib/pangea/analytics_data/analytics_update_service.dart
Normal file
164
lib/pangea/analytics_data/analytics_update_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
lib/pangea/analytics_data/analytics_updater_mixin.dart
Normal file
41
lib/pangea/analytics_data/analytics_updater_mixin.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
lib/pangea/analytics_data/construct_merge_table.dart
Normal file
140
lib/pangea/analytics_data/construct_merge_table.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
109
lib/pangea/analytics_data/derived_analytics_data_model.dart
Normal file
109
lib/pangea/analytics_data/derived_analytics_data_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
106
lib/pangea/analytics_data/level_up_analytics_service.dart
Normal file
106
lib/pangea/analytics_data/level_up_analytics_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
lib/pangea/analytics_misc/level_summary_extension.dart
Normal file
27
lib/pangea/analytics_misc/level_summary_extension.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
lib/pangea/analytics_misc/saved_analytics_extension.dart
Normal file
48
lib/pangea/analytics_misc/saved_analytics_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
lib/pangea/analytics_misc/user_lemma_info_extension.dart
Normal file
49
lib/pangea/analytics_misc/user_lemma_info_extension.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,9 +25,7 @@ enum ProgressIndicatorEnum {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ProgressIndicatorsExtension on ProgressIndicatorEnum {
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ProgressIndicatorEnum.wordsUsed:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ?? []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.") &&
|
||||
|
|
|
|||
|
|
@ -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#
|
||||
}
|
||||
|
||||
|
|
|
|||
16
pubspec.lock
16
pubspec.lock
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue