You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/analytics_misc/get_analytics_controller.dart

497 lines
17 KiB
Dart

import 'dart:async';
import 'dart:math';
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/message_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.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/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController extends BaseController {
final GetStorage analyticsBox = GetStorage("analytics_storage");
late PangeaController _pangeaController;
late MessageAnalyticsController 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;
perMessage = MessageAnalyticsController(
this,
);
}
LanguageModel? get _l2 => _pangeaController.languageController.userL2;
Client get _client => _pangeaController.matrixState.client;
// the minimum XP required for a given level
int get _minXPForLevel {
return _calculateMinXpForLevel(constructListModel.level);
}
// the minimum XP required for the next level
int get _minXPForNextLevel {
return _calculateMinXpForLevel(constructListModel.level + 1);
}
int get minXPForNextLevel => _minXPForNextLevel;
/// Calculates the minimum XP required for a specific level.
int _calculateMinXpForLevel(int level) {
if (level == 1) return 0; // Ensure level 1 starts at 0 XP
return ((100 / 8) * (2 * pow(level - 1, 2))).floor();
}
// 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 {
_client.updateAnalyticsRoomVisibility();
_client.addAnalyticsRoomsToSpaces();
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);
// 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.publicProfile?.xpOffset ?? 0;
constructListModel.updateConstructs(
[
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
],
offset,
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {},
);
} finally {
_updateAnalyticsStream();
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>();
_cache.clear();
// perMessage.dispose();
}
Future<void> _onAnalyticsUpdate(
AnalyticsUpdate analyticsUpdate,
) async {
if (analyticsUpdate.isLogout) return;
final oldLevel = constructListModel.level;
final offset =
_pangeaController.userController.publicProfile?.xpOffset ?? 0;
final prevUnlockedMorphs = constructListModel.unlockedGrammarLemmas.toSet();
constructListModel.updateConstructs(analyticsUpdate.newConstructs, offset);
final newUnlockedMorphs = constructListModel.unlockedGrammarLemmas
.toSet()
.difference(prevUnlockedMorphs);
if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await _getConstructs(forceUpdate: true);
}
if (oldLevel < constructListModel.level) {
await _onLevelUp(oldLevel, constructListModel.level);
}
if (oldLevel > constructListModel.level) {
await _onLevelDown(constructListModel.level, oldLevel);
}
if (newUnlockedMorphs.isNotEmpty) {
_onUnlockMorphLemmas(newUnlockedMorphs);
}
_updateAnalyticsStream(origin: analyticsUpdate.origin);
// 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.updatePublicProfile(
level: constructListModel.level,
);
}
void _updateAnalyticsStream({
AnalyticsUpdateOrigin? origin,
}) =>
analyticsStream.add(AnalyticsStreamUpdate(origin: origin));
Future<void> _onLevelUp(final int lowerLevel, final int upperLevel) async {
final result = await _generateLevelUpAnalyticsAndSaveToStateEvent(
lowerLevel,
upperLevel,
);
setState({
'level_up': constructListModel.level,
'analytics_room_id': _client.analyticsRoomLocal(_l2!)?.id,
"construct_summary_state_event_id": result?.stateEventId,
"construct_summary": result?.summary,
});
}
Future<void> _onLevelDown(final int lowerLevel, final int upperLevel) async {
final offset =
_calculateMinXpForLevel(lowerLevel) - constructListModel.totalXP;
await _pangeaController.userController.addXPOffset(offset);
constructListModel.updateConstructs(
[],
_pangeaController.userController.publicProfile!.xpOffset!,
);
}
void _onUnlockMorphLemmas(Set<ConstructIdentifier> unlocked) {
setState({'unlocked_constructs': unlocked});
}
/// 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
_pangeaController.putAnalytics
.clearMessagesSinceUpdate(clearDrafts: true);
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 {};
}
}
/// 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 (_pangeaController.matrixState.client.prevBatch == null) {
await _pangeaController.matrixState.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<GenerateConstructSummaryResult?>
_generateLevelUpAnalyticsAndSaveToStateEvent(
final int lowerLevel,
final int upperLevel,
) async {
// generate level up analytics as a construct summary
ConstructSummary summary;
try {
final int maxXP = _calculateMinXpForLevel(upperLevel);
final int minXP = _calculateMinXpForLevel(lowerLevel);
int diffXP = maxXP - minXP;
if (diffXP < 0) diffXP = 0;
// compute construct use of current level
final List<OneConstructUse> constructUseOfCurrentLevel = [];
int score = 0;
for (final use in constructListModel.uses) {
constructUseOfCurrentLevel.add(use);
score += use.pointValue;
if (score >= diffXP) break;
}
// extract construct use message bodies for analytics
List<String?>? constructUseMessageContentBodies = [];
for (final use in constructUseOfCurrentLevel) {
try {
final useMessage = await use.getEvent(_client);
final useMessageBody = useMessage?.content["body"];
if (useMessageBody is String) {
constructUseMessageContentBodies.add(useMessageBody);
} else {
constructUseMessageContentBodies.add(null);
}
} catch (e) {
constructUseMessageContentBodies.add(null);
}
}
if (constructUseMessageContentBodies.length !=
constructUseOfCurrentLevel.length) {
constructUseMessageContentBodies = null;
}
final request = ConstructSummaryRequest(
constructs: constructUseOfCurrentLevel,
constructUseMessageContentBodies: constructUseMessageContentBodies,
language: _l2!.langCodeShort,
upperLevel: upperLevel,
lowerLevel: lowerLevel,
);
final response = await ConstructRepo.generateConstructSummary(request);
summary = response.summary;
} catch (e) {
debugPrint("Error generating level up analytics: $e");
ErrorHandler.logError(e: e, data: {'e': e});
return null;
}
String stateEventId;
try {
final Room? analyticsRoom = _client.analyticsRoomLocal(_l2!);
if (analyticsRoom == null) {
ErrorHandler.logError(
e: e,
data: {'e': e, 'message': "Analytics room not found for user"},
);
return null;
}
stateEventId = await _client.setRoomStateWithKey(
analyticsRoom.id,
PangeaEventTypes.constructSummary,
'',
summary.toJson(),
);
} catch (e) {
debugPrint("Error saving construct summary room: $e");
ErrorHandler.logError(e: e, data: {'e': e});
return null;
}
return GenerateConstructSummaryResult(
stateEventId: stateEventId,
summary: summary,
);
}
}
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 AnalyticsUpdateOrigin? origin;
AnalyticsStreamUpdate({
this.origin,
});
}