update ConstructListModel to get all analytics metrics

pull/1476/head^2^2
ggurdin 1 year ago
parent bfa303b494
commit d91afc6e05
No known key found for this signature in database
GPG Key ID: A01CB41737CBB478

@ -14,7 +14,6 @@ import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics /// A minimized version of AnalyticsController that get the logged in user's analytics
@ -22,143 +21,70 @@ class GetAnalyticsController {
late PangeaController _pangeaController; late PangeaController _pangeaController;
final List<AnalyticsCacheEntry> _cache = []; final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription; StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
CachedStreamController<AnalyticsStreamUpdate> analyticsStream = StreamController<AnalyticsStreamUpdate> analyticsStream =
CachedStreamController<AnalyticsStreamUpdate>(); StreamController<AnalyticsStreamUpdate>();
ConstructListModel vocabModel = ConstructListModel(
type: ConstructTypeEnum.vocab,
uses: [],
);
ConstructListModel grammarModel = ConstructListModel(
type: ConstructTypeEnum.morph,
uses: [],
);
List<OneConstructUse> get allConstructUses {
final List<OneConstructUse> storedUses = getConstructsLocal() ?? [];
final List<OneConstructUse> localUses = locallyCachedConstructs;
final List<OneConstructUse> allConstructs = [
...storedUses,
...localUses,
];
return allConstructs;
}
/// The previous XP points of the user, before the last update. ConstructListModel constructListModel = ConstructListModel(uses: []);
/// Used for animating analytics updates.
int? prevXP;
GetAnalyticsController(PangeaController pangeaController) { GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController; _pangeaController = pangeaController;
} }
String? get l2Code => _pangeaController.languageController.userL2?.langCode; String? get _l2Code => _pangeaController.languageController.userL2?.langCode;
Client get client => _pangeaController.matrixState.client; Client get _client => _pangeaController.matrixState.client;
int get currentXP => calcXP(allConstructUses);
int get localXP => calcXP(locallyCachedConstructs);
int get serverXP => currentXP - localXP;
/// Get the current level based on the number of xp points
/// The formula is calculated from XP and modeled on RPG games
int get level => 1 + sqrt((1 + 8 * currentXP / 100) / 2).floor();
// the minimum XP required for a given level // the minimum XP required for a given level
double get minXPForLevel { double get _minXPForLevel {
return 12.5 * (2 * pow(level - 1, 2) - 1); return 12.5 * (2 * pow(constructListModel.level - 1, 2) - 1);
} }
// the minimum XP required for the next level // the minimum XP required for the next level
double get minXPForNextLevel { double get _minXPForNextLevel {
return 12.5 * (2 * pow(level, 2) - 1); return 12.5 * (2 * pow(constructListModel.level, 2) - 1);
} }
// the progress within the current level as a percentage (0.0 to 1.0) // the progress within the current level as a percentage (0.0 to 1.0)
double get levelProgress { double get levelProgress {
final progress = final progress = (constructListModel.totalXP - _minXPForLevel) /
(currentXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel); (_minXPForNextLevel - _minXPForLevel);
return progress >= 0 ? progress : 0;
}
double get serverLevelProgress {
final progress =
(serverXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel);
return progress >= 0 ? progress : 0; return progress >= 0 ? progress : 0;
} }
void initialize() { void initialize() {
_analyticsUpdateSubscription ??= _pangeaController _analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream .putAnalytics.analyticsUpdateStream.stream
.listen(onAnalyticsUpdate); .listen(_onAnalyticsUpdate);
_pangeaController.putAnalytics.lastUpdatedCompleter.future.then((_) { _pangeaController.putAnalytics.lastUpdatedCompleter.future.then((_) {
getConstructs().then((_) { _getConstructs().then((_) {
vocabModel.updateConstructs(allConstructUses); constructListModel.updateConstructs([
grammarModel.updateConstructs(allConstructUses); ...(_getConstructsLocal() ?? []),
updateAnalyticsStream(); ..._locallyCachedConstructs,
]);
_updateAnalyticsStream();
}); });
}); });
} }
/// Clear all cached analytics data. /// Clear all cached analytics data.
void dispose() { void dispose() {
constructListModel.dispose();
_analyticsUpdateSubscription?.cancel(); _analyticsUpdateSubscription?.cancel();
_analyticsUpdateSubscription = null; _analyticsUpdateSubscription = null;
_cache.clear(); _cache.clear();
analyticsStream.add(AnalyticsStreamUpdate(constructs: []));
prevXP = null;
} }
Future<void> onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { Future<void> _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
vocabModel.updateConstructs(analyticsUpdate.newConstructs);
grammarModel.updateConstructs(analyticsUpdate.newConstructs);
if (analyticsUpdate.isLogout) return; if (analyticsUpdate.isLogout) return;
constructListModel.updateConstructs(analyticsUpdate.newConstructs);
if (analyticsUpdate.type == AnalyticsUpdateType.server) { if (analyticsUpdate.type == AnalyticsUpdateType.server) {
await getConstructs(forceUpdate: true); await _getConstructs(forceUpdate: true);
}
updateAnalyticsStream(origin: analyticsUpdate.origin);
}
void updateAnalyticsStream({AnalyticsUpdateOrigin? origin}) {
// if there are no construct uses, or if the last update in this
// stream has the same length as this update, don't update the stream
if (allConstructUses.isEmpty ||
allConstructUses.length == analyticsStream.value?.constructs.length) {
return;
}
// set the previous XP to the currentXP
if (analyticsStream.value != null &&
analyticsStream.value!.constructs.isNotEmpty) {
prevXP = calcXP(analyticsStream.value!.constructs);
} }
_updateAnalyticsStream(origin: analyticsUpdate.origin);
// finally, add to the stream
analyticsStream.add(
AnalyticsStreamUpdate(
constructs: allConstructUses,
origin: origin,
),
);
} }
/// Calculates the user's xpPoints for their current L2, void _updateAnalyticsStream({AnalyticsUpdateOrigin? origin}) {
/// based on matrix analytics event and locally cached data. analyticsStream.add(AnalyticsStreamUpdate(origin: origin));
/// Has to be async because cached matrix events may be out of date,
/// and updating those is async.
int calcXP(List<OneConstructUse> constructs) {
final words = ConstructListModel(
uses: constructs,
type: ConstructTypeEnum.vocab,
);
final morphs = ConstructListModel(
uses: constructs,
type: ConstructTypeEnum.morph,
);
return words.points + morphs.points;
} }
/// A local cache of eventIds and construct uses for messages sent since the last update. /// A local cache of eventIds and construct uses for messages sent since the last update.
@ -200,7 +126,7 @@ class GetAnalyticsController {
} }
/// A flat list of all locally cached construct uses /// A flat list of all locally cached construct uses
List<OneConstructUse> get locallyCachedConstructs => List<OneConstructUse> get _locallyCachedConstructs =>
messagesSinceUpdate.values.expand((e) => e).toList(); messagesSinceUpdate.values.expand((e) => e).toList();
/// A flat list of all locally cached construct uses that are not drafts /// A flat list of all locally cached construct uses that are not drafts
@ -211,13 +137,13 @@ class GetAnalyticsController {
.toList(); .toList();
/// Get a list of all constructs used by the logged in user in their current L2 /// Get a list of all constructs used by the logged in user in their current L2
Future<List<OneConstructUse>> getConstructs({ Future<List<OneConstructUse>> _getConstructs({
bool forceUpdate = false, bool forceUpdate = false,
ConstructTypeEnum? constructType, ConstructTypeEnum? constructType,
}) async { }) async {
// if the user isn't logged in, return an empty list // if the user isn't logged in, return an empty list
if (client.userID == null) return []; if (_client.userID == null) return [];
await client.roomsLoading; await _client.roomsLoading;
// don't try to get constructs until last updated time has been loaded // don't try to get constructs until last updated time has been loaded
await _pangeaController.putAnalytics.lastUpdatedCompleter.future; await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
@ -225,7 +151,7 @@ class GetAnalyticsController {
// if forcing a refreshing, clear the cache // if forcing a refreshing, clear the cache
if (forceUpdate) _cache.clear(); if (forceUpdate) _cache.clear();
final List<OneConstructUse>? local = getConstructsLocal( final List<OneConstructUse>? local = _getConstructsLocal(
constructType: constructType, constructType: constructType,
); );
@ -239,7 +165,7 @@ class GetAnalyticsController {
// get all the construct events for the user from analytics room // get all the construct events for the user from analytics room
// and convert their content into a list of construct uses // and convert their content into a list of construct uses
final List<ConstructAnalyticsEvent> constructEvents = final List<ConstructAnalyticsEvent> constructEvents =
await allMyConstructs(); await _allMyConstructs();
final List<OneConstructUse> uses = []; final List<OneConstructUse> uses = [];
for (final event in constructEvents) { for (final event in constructEvents) {
@ -248,7 +174,7 @@ class GetAnalyticsController {
// if there isn't already a valid, local cache, cache the filtered uses // if there isn't already a valid, local cache, cache the filtered uses
if (local == null) { if (local == null) {
cacheConstructs( _cacheConstructs(
constructType: constructType, constructType: constructType,
uses: uses, uses: uses,
); );
@ -261,32 +187,33 @@ class GetAnalyticsController {
Future<DateTime?> myAnalyticsLastUpdated() async { Future<DateTime?> myAnalyticsLastUpdated() async {
// this function gets called soon after login, so first // 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 // make sure that the user's l2 is loaded, if the user has set their l2
if (client.userID != null && l2Code == null) { if (_client.userID != null && _l2Code == null) {
await _pangeaController.matrixState.client.waitForAccountData(); await _pangeaController.matrixState.client.waitForAccountData();
if (l2Code == null) return null; if (_l2Code == null) return null;
} }
final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); final Room? analyticsRoom = _client.analyticsRoomLocal(_l2Code!);
if (analyticsRoom == null) return null; if (analyticsRoom == null) return null;
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
client.userID!, _client.userID!,
); );
return lastUpdated; return lastUpdated;
} }
/// Get all the construct analytics events for the logged in user /// Get all the construct analytics events for the logged in user
Future<List<ConstructAnalyticsEvent>> allMyConstructs() async { Future<List<ConstructAnalyticsEvent>> _allMyConstructs() async {
if (l2Code == null) return []; if (_l2Code == null) return [];
final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); final Room? analyticsRoom = _client.analyticsRoomLocal(_l2Code!);
if (analyticsRoom == null) return []; if (analyticsRoom == null) return [];
return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? []; return await analyticsRoom.getAnalyticsEvents(userId: _client.userID!) ??
[];
} }
/// Get the cached construct uses for the current user, if it exists /// Get the cached construct uses for the current user, if it exists
List<OneConstructUse>? getConstructsLocal({ List<OneConstructUse>? _getConstructsLocal({
ConstructTypeEnum? constructType, ConstructTypeEnum? constructType,
}) { }) {
final index = _cache.indexWhere( final index = _cache.indexWhere(
(e) => e.type == constructType && e.langCode == l2Code, (e) => e.type == constructType && e.langCode == _l2Code,
); );
if (index > -1) { if (index > -1) {
@ -302,15 +229,15 @@ class GetAnalyticsController {
} }
/// Cache the construct uses for the current user /// Cache the construct uses for the current user
void cacheConstructs({ void _cacheConstructs({
required List<OneConstructUse> uses, required List<OneConstructUse> uses,
ConstructTypeEnum? constructType, ConstructTypeEnum? constructType,
}) { }) {
if (l2Code == null) return; if (_l2Code == null) return;
final entry = AnalyticsCacheEntry( final entry = AnalyticsCacheEntry(
type: constructType, type: constructType,
uses: List.from(uses), uses: List.from(uses),
langCode: l2Code!, langCode: _l2Code!,
); );
_cache.add(entry); _cache.add(entry);
} }
@ -350,11 +277,9 @@ class AnalyticsCacheEntry {
} }
class AnalyticsStreamUpdate { class AnalyticsStreamUpdate {
final List<OneConstructUse> constructs;
final AnalyticsUpdateOrigin? origin; final AnalyticsUpdateOrigin? origin;
AnalyticsStreamUpdate({ AnalyticsStreamUpdate({
required this.constructs,
this.origin, this.origin,
}); });
} }

@ -12,7 +12,6 @@ import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/cached_stream_controller.dart';
enum AnalyticsUpdateType { server, local } enum AnalyticsUpdateType { server, local }
@ -21,15 +20,15 @@ enum AnalyticsUpdateType { server, local }
/// 2) constructs used by the user, both in sending messages and doing practice activities /// 2) constructs used by the user, both in sending messages and doing practice activities
class PutAnalyticsController extends BaseController<AnalyticsStream> { class PutAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController; late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdate> analyticsUpdateStream = StreamController<AnalyticsUpdate> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdate>(); StreamController<AnalyticsUpdate>();
StreamSubscription<AnalyticsStream>? _analyticsStream; StreamSubscription<AnalyticsStream>? _analyticsStream;
StreamSubscription? _languageStream; StreamSubscription? _languageStream;
Timer? _updateTimer; Timer? _updateTimer;
Client get _client => _pangeaController.matrixState.client; Client get _client => _pangeaController.matrixState.client;
String? get userL2 => _pangeaController.languageController.activeL2Code(); String? get _userL2 => _pangeaController.languageController.activeL2Code();
/// the last time that matrix analytics events were updated for the user's current l2 /// the last time that matrix analytics events were updated for the user's current l2
DateTime? lastUpdated; DateTime? lastUpdated;
@ -133,7 +132,7 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
if (constructs.isEmpty) return; if (constructs.isEmpty) return;
final level = _pangeaController.getAnalytics.level; final level = _pangeaController.getAnalytics.constructListModel.level;
_addLocalMessage(eventID, constructs).then( _addLocalMessage(eventID, constructs).then(
(_) { (_) {
@ -206,7 +205,7 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
} }
} }
final level = _pangeaController.getAnalytics.level; final level = _pangeaController.getAnalytics.constructListModel.level;
// the list 'uses' gets altered in the _addLocalMessage method, // the list 'uses' gets altered in the _addLocalMessage method,
// so copy it here to that the list of new uses is accurate // so copy it here to that the list of new uses is accurate
@ -272,7 +271,8 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
return; return;
} }
final int newLevel = _pangeaController.getAnalytics.level; final int newLevel =
_pangeaController.getAnalytics.constructListModel.level;
newLevel > prevLevel newLevel > prevLevel
? sendLocalAnalyticsToAnalyticsRoom() ? sendLocalAnalyticsToAnalyticsRoom()
: analyticsUpdateStream.add( : analyticsUpdateStream.add(
@ -374,7 +374,7 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
if (cachedConstructs.isEmpty || onlyDraft) return; if (cachedConstructs.isEmpty || onlyDraft) return;
// if missing important info, don't send analytics. Could happen if user just signed up. // if missing important info, don't send analytics. Could happen if user just signed up.
final l2Code = l2Override ?? userL2; final l2Code = l2Override ?? _userL2;
if (l2Code == null || _client.userID == null) return; if (l2Code == null || _client.userID == null) return;
// analytics room for the user and current target language // analytics room for the user and current target language

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/constants/analytics_constants.dart';
import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart'; import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart';
import 'package:fluffychat/pangea/enum/analytics/parts_of_speech_enum.dart'; import 'package:fluffychat/pangea/enum/analytics/parts_of_speech_enum.dart';
import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -43,6 +44,15 @@ extension ConstructExtension on ConstructTypeEnum {
return null; return null;
} }
} }
ProgressIndicatorEnum get indicator {
switch (this) {
case ConstructTypeEnum.morph:
return ProgressIndicatorEnum.morphsUsed;
case ConstructTypeEnum.vocab:
return ProgressIndicatorEnum.wordsUsed;
}
}
} }
class ConstructTypeUtil { class ConstructTypeUtil {

@ -1,3 +1,4 @@
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -54,4 +55,15 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum {
return L10n.of(context)!.grammar; return L10n.of(context)!.grammar;
} }
} }
ConstructTypeEnum get constructType {
switch (this) {
case ProgressIndicatorEnum.wordsUsed:
return ConstructTypeEnum.vocab;
case ProgressIndicatorEnum.morphsUsed:
return ConstructTypeEnum.morph;
default:
return ConstructTypeEnum.vocab;
}
}
} }

@ -1,3 +1,5 @@
import 'dart:math';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
@ -5,20 +7,36 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activ
/// A wrapper around a list of [OneConstructUse]s, used to simplify /// A wrapper around a list of [OneConstructUse]s, used to simplify
/// the process of filtering / sorting / displaying the events. /// the process of filtering / sorting / displaying the events.
/// Takes a construct type and a list of events
class ConstructListModel { class ConstructListModel {
final ConstructTypeEnum? type; void dispose() {
_constructMap = {};
_constructList = [];
prevXP = 0;
totalXP = 0;
level = 0;
vocabLemmas = 0;
grammarLemmas = 0;
}
/// A map of lemmas to ConstructUses, each of which contains a lemma /// A map of lemmas to ConstructUses, each of which contains a lemma
/// key = lemmma + constructType.string, value = ConstructUses /// key = lemmma + constructType.string, value = ConstructUses
final Map<String, ConstructUses> _constructMap = {}; Map<String, ConstructUses> _constructMap = {};
/// Storing this to avoid re-running the sort operation each time this needs to /// 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. /// be accessed. It contains the same information as _constructMap, but sorted.
List<ConstructUses> constructList = []; List<ConstructUses> _constructList = [];
/// A map of categories to lists of ConstructUses
Map<String, List<ConstructUses>> _categoriesToUses = {};
/// Analytics data consumed by widgets. Updated each time new analytics come in.
int prevXP = 0;
int totalXP = 0;
int level = 0;
int vocabLemmas = 0;
int grammarLemmas = 0;
ConstructListModel({ ConstructListModel({
required this.type,
required List<OneConstructUse> uses, required List<OneConstructUse> uses,
}) { }) {
updateConstructs(uses); updateConstructs(uses);
@ -27,11 +45,10 @@ class ConstructListModel {
/// Given a list of new construct uses, update the map of construct /// Given a list of new construct uses, update the map of construct
/// IDs to ConstructUses and re-sort the list of ConstructUses /// IDs to ConstructUses and re-sort the list of ConstructUses
void updateConstructs(List<OneConstructUse> newUses) { void updateConstructs(List<OneConstructUse> newUses) {
final List<OneConstructUse> filteredUses = newUses _updateConstructMap(newUses);
.where((use) => use.constructType == type || type == null)
.toList();
_updateConstructMap(filteredUses);
_updateConstructList(); _updateConstructList();
_updateCategoriesToUses();
_updateMetrics();
} }
/// A map of lemmas to ConstructUses, each of which contains a lemma /// A map of lemmas to ConstructUses, each of which contains a lemma
@ -55,44 +72,64 @@ class ConstructListModel {
/// a list of uses, sorted by the number of uses /// a list of uses, sorted by the number of uses
void _updateConstructList() { void _updateConstructList() {
// TODO check how expensive this is // TODO check how expensive this is
constructList = _constructMap.values.toList(); _constructList = _constructMap.values.toList();
constructList.sort((a, b) { _constructList.sort((a, b) {
final comp = b.uses.length.compareTo(a.uses.length); final comp = b.uses.length.compareTo(a.uses.length);
if (comp != 0) return comp; if (comp != 0) return comp;
return a.lemma.compareTo(b.lemma); return a.lemma.compareTo(b.lemma);
}); });
} }
ConstructUses? getConstructUses(ConstructIdentifier identifier) { void _updateCategoriesToUses() {
return _constructMap[identifier.string]; _categoriesToUses = {};
for (final use in constructList()) {
_categoriesToUses[use.category] ??= [];
_categoriesToUses[use.category]!.add(use);
}
} }
List<ConstructUses> get constructListWithPoints => void _updateMetrics() {
constructList.where((constructUse) => constructUse.points > 0).toList(); vocabLemmas = constructList(type: ConstructTypeEnum.vocab)
.map((e) => e.lemma)
.toSet()
.length;
/// All unique lemmas used in the construct events with non-zero points grammarLemmas = constructList(type: ConstructTypeEnum.morph)
List<String> get lemmasWithPoints => .map((e) => e.lemma)
constructListWithPoints.map((e) => e.lemma).toSet().toList(); .toSet()
.length;
Map<String, List<ConstructUses>> get categoriesToUses { prevXP = totalXP;
final Map<String, List<ConstructUses>> categoriesMap = {}; totalXP = _constructList.fold<int>(
for (final use in constructListWithPoints) { 0,
categoriesMap[use.category] ??= []; (total, construct) => total + construct.points,
categoriesMap[use.category]!.add(use); );
} level = 1 + sqrt((1 + 8 * totalXP / 100) / 2).floor();
return categoriesMap;
} }
int get maxXPPerLemma => ConstructUses? getConstructUses(ConstructIdentifier identifier) {
type?.maxXPPerLemma ?? ConstructTypeEnum.vocab.maxXPPerLemma; return _constructMap[identifier.string];
}
/// The total number of points for all uses of this construct type List<ConstructUses> constructList({ConstructTypeEnum? type}) => _constructList
int get points { .where(
int totalPoints = 0; (constructUse) =>
for (final constructUse in _constructMap.values.toList()) { constructUse.points > 0 &&
totalPoints += constructUse.points; (type == null || constructUse.constructType == type),
} )
return totalPoints; .toList();
Map<String, List<ConstructUses>> categoriesToUses({ConstructTypeEnum? type}) {
if (type == null) return _categoriesToUses;
final entries = _categoriesToUses.entries.toList();
return Map.fromEntries(
entries.map((entry) {
return MapEntry(
entry.key,
entry.value.where((use) => use.constructType == type).toList(),
);
}).where((entry) => entry.value.isNotEmpty),
);
} }
} }

@ -142,7 +142,7 @@ class OneConstructUse {
ConstructIdentifier get identifier => ConstructIdentifier( ConstructIdentifier get identifier => ConstructIdentifier(
lemma: lemma!, lemma: lemma!,
type: constructType, type: constructType,
category: category, category: category ?? "",
); );
} }

@ -12,12 +12,12 @@ import 'package:sentry_flutter/sentry_flutter.dart';
class ConstructIdentifier { class ConstructIdentifier {
final String lemma; final String lemma;
final ConstructTypeEnum type; final ConstructTypeEnum type;
final String? category; final String category;
ConstructIdentifier({ ConstructIdentifier({
required this.lemma, required this.lemma,
required this.type, required this.type,
this.category, required this.category,
}); });
factory ConstructIdentifier.fromJson(Map<String, dynamic> json) { factory ConstructIdentifier.fromJson(Map<String, dynamic> json) {
@ -37,7 +37,7 @@ class ConstructIdentifier {
type: ConstructTypeEnum.values.firstWhere( type: ConstructTypeEnum.values.firstWhere(
(e) => e.string == json['type'], (e) => e.string == json['type'],
), ),
category: category, category: category ?? "",
); );
} catch (e, s) { } catch (e, s) {
debugger(when: kDebugMode); debugger(when: kDebugMode);
@ -70,7 +70,7 @@ class ConstructIdentifier {
} }
String get string => String get string =>
"$lemma-${type.string}${category != null ? "-$category" : "-other"}"; "$lemma-${type.string}${category != "" ? "-$category" : "-other"}";
} }
class CandidateMessage { class CandidateMessage {

@ -29,8 +29,10 @@ class PointsGainedAnimationState extends State<PointsGainedAnimation>
late Animation<double> _fadeAnimation; late Animation<double> _fadeAnimation;
StreamSubscription? _pointsSubscription; StreamSubscription? _pointsSubscription;
int? get _prevXP => MatrixState.pangeaController.getAnalytics.prevXP; int? get _prevXP =>
int? get _currentXP => MatrixState.pangeaController.getAnalytics.currentXP; MatrixState.pangeaController.getAnalytics.constructListModel.prevXP;
int? get _currentXP =>
MatrixState.pangeaController.getAnalytics.constructListModel.totalXP;
int? _addedPoints; int? _addedPoints;
@override @override

@ -1,18 +1,18 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
class AnalyticsPopup extends StatefulWidget { class AnalyticsPopup extends StatefulWidget {
final ProgressIndicatorEnum indicator; final ConstructTypeEnum type;
final ConstructListModel constructsModel;
final bool showGroups; final bool showGroups;
const AnalyticsPopup({ const AnalyticsPopup({
required this.indicator, required this.type,
required this.constructsModel,
this.showGroups = true, this.showGroups = true,
super.key, super.key,
}); });
@ -23,9 +23,14 @@ class AnalyticsPopup extends StatefulWidget {
class AnalyticsPopupState extends State<AnalyticsPopup> { class AnalyticsPopupState extends State<AnalyticsPopup> {
String? selectedCategory; String? selectedCategory;
ConstructListModel get _constructsModel =>
MatrixState.pangeaController.getAnalytics.constructListModel;
List<MapEntry<String, List<ConstructUses>>> get categoriesToUses { Map<String, List<ConstructUses>> get _categoriesToUses =>
final entries = widget.constructsModel.categoriesToUses.entries.toList(); _constructsModel.categoriesToUses(type: widget.type);
List<MapEntry<String, List<ConstructUses>>> get _sortedEntries {
final entries = _categoriesToUses.entries.toList();
// Sort the list with custom logic // Sort the list with custom logic
entries.sort((a, b) { entries.sort((a, b) {
// Check if one of the keys is 'Other' // Check if one of the keys is 'Other'
@ -51,7 +56,7 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
}); });
String categoryCopy(category) => String categoryCopy(category) =>
widget.constructsModel.type?.getDisplayCopy( widget.type.getDisplayCopy(
category, category,
context, context,
) ?? ) ??
@ -61,10 +66,9 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget? dialogContent; Widget? dialogContent;
final bool hasNoData = final bool hasNoData =
widget.constructsModel.constructListWithPoints.isEmpty; _constructsModel.constructList(type: widget.type).isEmpty;
final bool hasNoCategories = final bool hasNoCategories = _categoriesToUses.length == 1 &&
widget.constructsModel.categoriesToUses.length == 1 && _categoriesToUses.entries.first.key == "Other";
widget.constructsModel.categoriesToUses.keys.first == "Other";
if (selectedCategory != null) { if (selectedCategory != null) {
dialogContent = Column( dialogContent = Column(
@ -75,7 +79,7 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
), ),
Expanded( Expanded(
child: ConstructsTileList( child: ConstructsTileList(
widget.constructsModel.categoriesToUses[selectedCategory]!, _categoriesToUses[selectedCategory]!,
), ),
), ),
], ],
@ -84,13 +88,17 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
dialogContent = Center(child: Text(L10n.of(context)!.noDataFound)); dialogContent = Center(child: Text(L10n.of(context)!.noDataFound));
} else if (hasNoCategories || !widget.showGroups) { } else if (hasNoCategories || !widget.showGroups) {
dialogContent = ConstructsTileList( dialogContent = ConstructsTileList(
widget.constructsModel.constructListWithPoints, _constructsModel.constructList(type: widget.type).sorted((a, b) {
final comp = b.points.compareTo(a.points);
if (comp != 0) return comp;
return a.lemma.compareTo(b.lemma);
}),
); );
} else { } else {
dialogContent = ListView.builder( dialogContent = ListView.builder(
itemCount: categoriesToUses.length, itemCount: _sortedEntries.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final category = categoriesToUses[index]; final category = _sortedEntries[index];
return Column( return Column(
children: [ children: [
ListTile( ListTile(
@ -115,7 +123,7 @@ class AnalyticsPopupState extends State<AnalyticsPopup> {
borderRadius: BorderRadius.circular(20.0), borderRadius: BorderRadius.circular(20.0),
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.indicator.tooltip(context)), title: Text(widget.type.indicator.tooltip(context)),
leading: IconButton( leading: IconButton(
icon: selectedCategory == null icon: selectedCategory == null
? const Icon(Icons.close) ? const Icon(Icons.close)

@ -0,0 +1,26 @@
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class LearningProgressBar extends StatelessWidget {
final int totalXP;
const LearningProgressBar({
required this.totalXP,
super.key,
});
@override
Widget build(BuildContext context) {
return ProgressBar(
levelBars: [
LevelBarDetails(
fillColor: Theme.of(context).colorScheme.primary,
currentPoints: totalXP,
widthMultiplier:
MatrixState.pangeaController.getAnalytics.levelProgress,
),
],
);
}
}

@ -2,18 +2,15 @@ import 'dart:async';
import 'package:fluffychat/pages/chat_list/client_chooser_button.dart'; import 'package:fluffychat/pages/chat_list/client_chooser_button.dart';
import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar.dart';
import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/level_badge.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart';
import 'package:fluffychat/pangea/widgets/flag.dart'; import 'package:fluffychat/pangea/widgets/flag.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// A summary of "My Analytics" shown at the top of the chat list /// A summary of "My Analytics" shown at the top of the chat list
@ -21,148 +18,58 @@ import 'package:flutter/material.dart';
/// messages sent, words used, and error types, which can /// messages sent, words used, and error types, which can
/// be clicked to access more fine-grained analytics data. /// be clicked to access more fine-grained analytics data.
class LearningProgressIndicators extends StatefulWidget { class LearningProgressIndicators extends StatefulWidget {
const LearningProgressIndicators({ const LearningProgressIndicators({super.key});
super.key,
});
@override @override
LearningProgressIndicatorsState createState() => State<LearningProgressIndicators> createState() =>
LearningProgressIndicatorsState(); LearningProgressIndicatorsState();
} }
class LearningProgressIndicatorsState class LearningProgressIndicatorsState
extends State<LearningProgressIndicators> { extends State<LearningProgressIndicators> {
final PangeaController _pangeaController = MatrixState.pangeaController; ConstructListModel get _constructsModel =>
MatrixState.pangeaController.getAnalytics.constructListModel;
bool _loading = true;
/// A stream subscription to listen for updates to StreamSubscription<AnalyticsStreamUpdate>? _analyticsSubscription;
/// the analytics data, either locally or from events
StreamSubscription<AnalyticsStreamUpdate>? _analyticsUpdateSubscription;
bool loading = true;
// Some buggy stuff is happening with this data not being updated at login, so switching
// to stateful variables for now. Will switch this back later when I have more time to
// figure out why it's now working.
// int get serverXP => _pangeaController.analytics.serverXP;
// int get totalXP => _pangeaController.analytics.currentXP;
// int get level => _pangeaController.analytics.level;
List<OneConstructUse> currentConstructs = [];
int get currentXP => _pangeaController.getAnalytics.calcXP(currentConstructs);
int get localXP => _pangeaController.getAnalytics.calcXP(
_pangeaController.getAnalytics.locallyCachedConstructs,
);
int get serverXP => currentXP - localXP;
int get level => _pangeaController.getAnalytics.level;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
updateAnalyticsData( _analyticsSubscription = MatrixState
_pangeaController.getAnalytics.analyticsStream.value?.constructs ?? [], .pangeaController.getAnalytics.analyticsStream.stream
); .listen(updateData);
_analyticsUpdateSubscription = _pangeaController
.getAnalytics.analyticsStream.stream
.listen((update) => updateAnalyticsData(update.constructs));
} }
@override @override
void dispose() { void dispose() {
_analyticsUpdateSubscription?.cancel(); _analyticsSubscription?.cancel();
_analyticsSubscription = null;
super.dispose(); super.dispose();
} }
/// Update the analytics data shown in the UI. This comes from a void updateData(AnalyticsStreamUpdate _) {
/// combination of stored events and locally cached data. if (_loading) _loading = false;
Future<void> updateAnalyticsData(List<OneConstructUse> constructs) async {
currentConstructs = constructs;
if (loading) loading = false;
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
/// Get the number of points for a given progress indicator int uniqueLemmas(ProgressIndicatorEnum indicator) {
ConstructListModel? getConstructsModel(ProgressIndicatorEnum indicator) {
switch (indicator) { switch (indicator) {
case ProgressIndicatorEnum.wordsUsed:
return _pangeaController.getAnalytics.vocabModel;
case ProgressIndicatorEnum.morphsUsed: case ProgressIndicatorEnum.morphsUsed:
return _pangeaController.getAnalytics.grammarModel; return _constructsModel.grammarLemmas;
default:
return null;
}
}
/// Get the number of points for a given progress indicator
int? getProgressPoints(ProgressIndicatorEnum indicator) {
switch (indicator) {
case ProgressIndicatorEnum.wordsUsed: case ProgressIndicatorEnum.wordsUsed:
return _pangeaController return _constructsModel.vocabLemmas;
.getAnalytics.vocabModel.lemmasWithPoints.length; default:
case ProgressIndicatorEnum.morphsUsed: return 0;
return _pangeaController
.getAnalytics.grammarModel.lemmasWithPoints.length;
case ProgressIndicatorEnum.level:
return level;
} }
} }
// double get levelBarWidth => FluffyThemes.columnWidth - (32 * 2) - 25;
Color levelColor(int level) {
final colors = [
const Color.fromARGB(255, 33, 97, 140), // Dark blue
const Color.fromARGB(255, 186, 104, 200), // Soft purple
const Color.fromARGB(255, 123, 31, 162), // Deep purple
const Color.fromARGB(255, 0, 150, 136), // Teal
const Color.fromARGB(255, 247, 143, 143), // Light pink
const Color.fromARGB(255, 220, 20, 60), // Crimson red
];
return colors[level % colors.length];
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (Matrix.of(context).client.userID == null) { if (Matrix.of(context).client.userID == null) {
return const SizedBox(); return const SizedBox();
} }
final progressBar = ProgressBar(
levelBars: [
LevelBarDetails(
fillColor: kDebugMode
? const Color.fromARGB(255, 0, 190, 83)
: Theme.of(context).colorScheme.primary,
currentPoints: currentXP,
widthMultiplier: _pangeaController.getAnalytics.levelProgress,
),
LevelBarDetails(
fillColor: Theme.of(context).colorScheme.primary,
currentPoints: serverXP,
widthMultiplier: _pangeaController.getAnalytics.serverLevelProgress,
),
],
);
final levelBadge = Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: levelColor(level),
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 5,
offset: const Offset(5, 0),
),
],
),
child: Center(
child: Text(
"$level",
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
);
return Row( return Row(
children: [ children: [
const ClientChooserButton(), const ClientChooserButton(),
@ -180,22 +87,19 @@ class LearningProgressIndicatorsState
(indicator) => Padding( (indicator) => Padding(
padding: const EdgeInsets.only(right: 10), padding: const EdgeInsets.only(right: 10),
child: ProgressIndicatorBadge( child: ProgressIndicatorBadge(
points: getProgressPoints(indicator), points: uniqueLemmas(indicator),
loading: _loading,
onTap: () { onTap: () {
final model = getConstructsModel(indicator);
if (model == null) return;
showDialog<AnalyticsPopup>( showDialog<AnalyticsPopup>(
context: context, context: context,
builder: (c) => AnalyticsPopup( builder: (c) => AnalyticsPopup(
indicator: indicator, type: indicator.constructType,
constructsModel: model,
showGroups: indicator == showGroups: indicator ==
ProgressIndicatorEnum.morphsUsed, ProgressIndicatorEnum.morphsUsed,
), ),
); );
}, },
progressIndicator: indicator, indicator: indicator,
loading: loading,
), ),
), ),
) )
@ -207,7 +111,8 @@ class LearningProgressIndicatorsState
builder: (c) => const SettingsLearning(), builder: (c) => const SettingsLearning(),
), ),
child: LanguageFlag( child: LanguageFlag(
language: _pangeaController.languageController.userL2, language: MatrixState
.pangeaController.languageController.userL2,
size: 24, size: 24,
), ),
), ),
@ -219,8 +124,17 @@ class LearningProgressIndicatorsState
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Positioned(left: 16, right: 0, child: progressBar), Positioned(
Positioned(left: 0, child: levelBadge), left: 16,
right: 0,
child: LearningProgressBar(
totalXP: _constructsModel.totalXP,
),
),
Positioned(
left: 0,
child: LevelBadge(level: _constructsModel.level),
),
], ],
), ),
), ),

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
class LevelBadge extends StatelessWidget {
final int level;
const LevelBadge({
required this.level,
super.key,
});
Color levelColor(int level) {
final colors = [
const Color.fromARGB(255, 33, 97, 140), // Dark blue
const Color.fromARGB(255, 186, 104, 200), // Soft purple
const Color.fromARGB(255, 123, 31, 162), // Deep purple
const Color.fromARGB(255, 0, 150, 136), // Teal
const Color.fromARGB(255, 247, 143, 143), // Light pink
const Color.fromARGB(255, 220, 20, 60), // Crimson red
];
return colors[level % colors.length];
}
@override
Widget build(BuildContext context) {
return Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: levelColor(level),
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 5,
offset: const Offset(5, 0),
),
],
),
child: Center(
child: Text(
"$level",
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
);
}
}

@ -3,36 +3,36 @@ import 'package:flutter/material.dart';
/// A badge that represents one learning progress indicator (i.e., construct uses) /// A badge that represents one learning progress indicator (i.e., construct uses)
class ProgressIndicatorBadge extends StatelessWidget { class ProgressIndicatorBadge extends StatelessWidget {
final int? points;
final VoidCallback onTap;
final ProgressIndicatorEnum progressIndicator;
final bool loading; final bool loading;
final int points;
final VoidCallback onTap;
final ProgressIndicatorEnum indicator;
const ProgressIndicatorBadge({ const ProgressIndicatorBadge({
super.key, super.key,
required this.points,
required this.onTap, required this.onTap,
required this.progressIndicator, required this.indicator,
required this.loading, required this.loading,
required this.points,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Tooltip( return Tooltip(
message: progressIndicator.tooltip(context), message: indicator.tooltip(context),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
progressIndicator.icon, indicator.icon,
color: progressIndicator.color(context), color: indicator.color(context),
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
!loading !loading
? Text( ? Text(
points?.toString() ?? '0', points.toString(),
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

@ -5,7 +5,6 @@ import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Seperated out the target tokens from the practice activity card /// Seperated out the target tokens from the practice activity card
@ -26,12 +25,12 @@ class TargetTokensController {
_targetTokens = await _initialize(pangeaMessageEvent); _targetTokens = await _initialize(pangeaMessageEvent);
final allConstructs = MatrixState // final allConstructs = MatrixState
.pangeaController.getAnalytics.analyticsStream.value?.constructs; // .pangeaController.getAnalytics.analyticsStream.value?.constructs;
await updateTokensWithConstructs( // await updateTokensWithConstructs(
allConstructs ?? [], // allConstructs ?? [],
pangeaMessageEvent, // pangeaMessageEvent,
); // );
return _targetTokens!; return _targetTokens!;
} }
@ -60,7 +59,7 @@ class TargetTokensController {
) async { ) async {
final ConstructListModel constructList = ConstructListModel( final ConstructListModel constructList = ConstructListModel(
uses: constructUses, uses: constructUses,
type: null, // type: null,
); );
_targetTokens ??= await _initialize(pangeaMessageEvent); _targetTokens ??= await _initialize(pangeaMessageEvent);
@ -76,6 +75,7 @@ class TargetTokensController {
ConstructIdentifier( ConstructIdentifier(
lemma: construct.id.lemma, lemma: construct.id.lemma,
type: construct.id.type, type: construct.id.type,
category: construct.id.category,
), ),
); );
if (constructUseModel != null) { if (constructUseModel != null) {

Loading…
Cancel
Save