From d91afc6e0558f0c35d1eb88702ad4fe413d51341 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Fri, 8 Nov 2024 13:58:56 -0500 Subject: [PATCH] update ConstructListModel to get all analytics metrics --- .../controllers/get_analytics_controller.dart | 167 +++++------------- .../controllers/put_analytics_controller.dart | 16 +- lib/pangea/enum/construct_type_enum.dart | 10 ++ lib/pangea/enum/progress_indicators_enum.dart | 12 ++ .../analytics/construct_list_model.dart | 105 +++++++---- .../models/analytics/constructs_model.dart | 2 +- .../practice_activity_model.dart | 8 +- .../widgets/animations/gain_points.dart | 6 +- .../analytics_popup/analytics_popup.dart | 40 +++-- .../learning_progress_bar.dart | 26 +++ .../learning_progress_indicators.dart | 160 ++++------------- .../analytics_summary/level_badge.dart | 46 +++++ .../analytics_summary/progress_indicator.dart | 18 +- .../target_tokens_controller.dart | 16 +- 14 files changed, 306 insertions(+), 326 deletions(-) create mode 100644 lib/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart create mode 100644 lib/pangea/widgets/chat_list/analytics_summary/level_badge.dart diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index f89492aca..b37149a30 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -14,7 +14,6 @@ import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import 'package:matrix/src/utils/cached_stream_controller.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; /// A minimized version of AnalyticsController that get the logged in user's analytics @@ -22,143 +21,70 @@ class GetAnalyticsController { late PangeaController _pangeaController; final List _cache = []; StreamSubscription? _analyticsUpdateSubscription; - CachedStreamController analyticsStream = - CachedStreamController(); - - ConstructListModel vocabModel = ConstructListModel( - type: ConstructTypeEnum.vocab, - uses: [], - ); - ConstructListModel grammarModel = ConstructListModel( - type: ConstructTypeEnum.morph, - uses: [], - ); - - List get allConstructUses { - final List storedUses = getConstructsLocal() ?? []; - final List localUses = locallyCachedConstructs; - - final List allConstructs = [ - ...storedUses, - ...localUses, - ]; - - return allConstructs; - } + StreamController analyticsStream = + StreamController(); - /// The previous XP points of the user, before the last update. - /// Used for animating analytics updates. - int? prevXP; + ConstructListModel constructListModel = ConstructListModel(uses: []); GetAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; } - String? get l2Code => _pangeaController.languageController.userL2?.langCode; - 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(); + String? get _l2Code => _pangeaController.languageController.userL2?.langCode; + Client get _client => _pangeaController.matrixState.client; // the minimum XP required for a given level - double get minXPForLevel { - return 12.5 * (2 * pow(level - 1, 2) - 1); + double get _minXPForLevel { + return 12.5 * (2 * pow(constructListModel.level - 1, 2) - 1); } // the minimum XP required for the next level - double get minXPForNextLevel { - return 12.5 * (2 * pow(level, 2) - 1); + double get _minXPForNextLevel { + return 12.5 * (2 * pow(constructListModel.level, 2) - 1); } // the progress within the current level as a percentage (0.0 to 1.0) double get levelProgress { - final progress = - (currentXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel); - return progress >= 0 ? progress : 0; - } - - double get serverLevelProgress { - final progress = - (serverXP - minXPForLevel) / (minXPForNextLevel - minXPForLevel); + final progress = (constructListModel.totalXP - _minXPForLevel) / + (_minXPForNextLevel - _minXPForLevel); return progress >= 0 ? progress : 0; } void initialize() { _analyticsUpdateSubscription ??= _pangeaController .putAnalytics.analyticsUpdateStream.stream - .listen(onAnalyticsUpdate); + .listen(_onAnalyticsUpdate); _pangeaController.putAnalytics.lastUpdatedCompleter.future.then((_) { - getConstructs().then((_) { - vocabModel.updateConstructs(allConstructUses); - grammarModel.updateConstructs(allConstructUses); - updateAnalyticsStream(); + _getConstructs().then((_) { + constructListModel.updateConstructs([ + ...(_getConstructsLocal() ?? []), + ..._locallyCachedConstructs, + ]); + _updateAnalyticsStream(); }); }); } /// Clear all cached analytics data. void dispose() { + constructListModel.dispose(); _analyticsUpdateSubscription?.cancel(); _analyticsUpdateSubscription = null; _cache.clear(); - analyticsStream.add(AnalyticsStreamUpdate(constructs: [])); - prevXP = null; } - Future onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { - vocabModel.updateConstructs(analyticsUpdate.newConstructs); - grammarModel.updateConstructs(analyticsUpdate.newConstructs); + Future _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { if (analyticsUpdate.isLogout) return; + constructListModel.updateConstructs(analyticsUpdate.newConstructs); if (analyticsUpdate.type == AnalyticsUpdateType.server) { - 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); + await _getConstructs(forceUpdate: true); } - - // finally, add to the stream - analyticsStream.add( - AnalyticsStreamUpdate( - constructs: allConstructUses, - origin: origin, - ), - ); + _updateAnalyticsStream(origin: analyticsUpdate.origin); } - /// Calculates the user's xpPoints for their current L2, - /// based on matrix analytics event and locally cached data. - /// Has to be async because cached matrix events may be out of date, - /// and updating those is async. - int calcXP(List constructs) { - final words = ConstructListModel( - uses: constructs, - type: ConstructTypeEnum.vocab, - ); - - final morphs = ConstructListModel( - uses: constructs, - type: ConstructTypeEnum.morph, - ); - return words.points + morphs.points; + void _updateAnalyticsStream({AnalyticsUpdateOrigin? origin}) { + analyticsStream.add(AnalyticsStreamUpdate(origin: origin)); } /// 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 - List get locallyCachedConstructs => + List get _locallyCachedConstructs => messagesSinceUpdate.values.expand((e) => e).toList(); /// A flat list of all locally cached construct uses that are not drafts @@ -211,13 +137,13 @@ class GetAnalyticsController { .toList(); /// Get a list of all constructs used by the logged in user in their current L2 - Future> getConstructs({ + Future> _getConstructs({ bool forceUpdate = false, ConstructTypeEnum? constructType, }) async { // if the user isn't logged in, return an empty list - if (client.userID == null) return []; - await client.roomsLoading; + if (_client.userID == null) return []; + await _client.roomsLoading; // don't try to get constructs until last updated time has been loaded await _pangeaController.putAnalytics.lastUpdatedCompleter.future; @@ -225,7 +151,7 @@ class GetAnalyticsController { // if forcing a refreshing, clear the cache if (forceUpdate) _cache.clear(); - final List? local = getConstructsLocal( + final List? local = _getConstructsLocal( constructType: constructType, ); @@ -239,7 +165,7 @@ class GetAnalyticsController { // get all the construct events for the user from analytics room // and convert their content into a list of construct uses final List constructEvents = - await allMyConstructs(); + await _allMyConstructs(); final List uses = []; 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 (local == null) { - cacheConstructs( + _cacheConstructs( constructType: constructType, uses: uses, ); @@ -261,32 +187,33 @@ class GetAnalyticsController { Future 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 && l2Code == null) { + if (_client.userID != null && _l2Code == null) { 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; final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( - client.userID!, + _client.userID!, ); return lastUpdated; } /// Get all the construct analytics events for the logged in user - Future> allMyConstructs() async { - if (l2Code == null) return []; - final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!); + Future> _allMyConstructs() async { + if (_l2Code == null) return []; + final Room? analyticsRoom = _client.analyticsRoomLocal(_l2Code!); 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 - List? getConstructsLocal({ + List? _getConstructsLocal({ ConstructTypeEnum? constructType, }) { final index = _cache.indexWhere( - (e) => e.type == constructType && e.langCode == l2Code, + (e) => e.type == constructType && e.langCode == _l2Code, ); if (index > -1) { @@ -302,15 +229,15 @@ class GetAnalyticsController { } /// Cache the construct uses for the current user - void cacheConstructs({ + void _cacheConstructs({ required List uses, ConstructTypeEnum? constructType, }) { - if (l2Code == null) return; + if (_l2Code == null) return; final entry = AnalyticsCacheEntry( type: constructType, uses: List.from(uses), - langCode: l2Code!, + langCode: _l2Code!, ); _cache.add(entry); } @@ -350,11 +277,9 @@ class AnalyticsCacheEntry { } class AnalyticsStreamUpdate { - final List constructs; final AnalyticsUpdateOrigin? origin; AnalyticsStreamUpdate({ - required this.constructs, this.origin, }); } diff --git a/lib/pangea/controllers/put_analytics_controller.dart b/lib/pangea/controllers/put_analytics_controller.dart index 9cf34e3f5..87d407761 100644 --- a/lib/pangea/controllers/put_analytics_controller.dart +++ b/lib/pangea/controllers/put_analytics_controller.dart @@ -12,7 +12,6 @@ import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; -import 'package:matrix/src/utils/cached_stream_controller.dart'; 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 class PutAnalyticsController extends BaseController { late PangeaController _pangeaController; - CachedStreamController analyticsUpdateStream = - CachedStreamController(); + StreamController analyticsUpdateStream = + StreamController(); StreamSubscription? _analyticsStream; StreamSubscription? _languageStream; Timer? _updateTimer; 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 DateTime? lastUpdated; @@ -133,7 +132,7 @@ class PutAnalyticsController extends BaseController { if (constructs.isEmpty) return; - final level = _pangeaController.getAnalytics.level; + final level = _pangeaController.getAnalytics.constructListModel.level; _addLocalMessage(eventID, constructs).then( (_) { @@ -206,7 +205,7 @@ class PutAnalyticsController extends BaseController { } } - final level = _pangeaController.getAnalytics.level; + final level = _pangeaController.getAnalytics.constructListModel.level; // the list 'uses' gets altered in the _addLocalMessage method, // so copy it here to that the list of new uses is accurate @@ -272,7 +271,8 @@ class PutAnalyticsController extends BaseController { return; } - final int newLevel = _pangeaController.getAnalytics.level; + final int newLevel = + _pangeaController.getAnalytics.constructListModel.level; newLevel > prevLevel ? sendLocalAnalyticsToAnalyticsRoom() : analyticsUpdateStream.add( @@ -374,7 +374,7 @@ class PutAnalyticsController extends BaseController { if (cachedConstructs.isEmpty || onlyDraft) return; // 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; // analytics room for the user and current target language diff --git a/lib/pangea/enum/construct_type_enum.dart b/lib/pangea/enum/construct_type_enum.dart index 6458f5939..0ae45961b 100644 --- a/lib/pangea/enum/construct_type_enum.dart +++ b/lib/pangea/enum/construct_type_enum.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:fluffychat/pangea/constants/analytics_constants.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/progress_indicators_enum.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -43,6 +44,15 @@ extension ConstructExtension on ConstructTypeEnum { return null; } } + + ProgressIndicatorEnum get indicator { + switch (this) { + case ConstructTypeEnum.morph: + return ProgressIndicatorEnum.morphsUsed; + case ConstructTypeEnum.vocab: + return ProgressIndicatorEnum.wordsUsed; + } + } } class ConstructTypeUtil { diff --git a/lib/pangea/enum/progress_indicators_enum.dart b/lib/pangea/enum/progress_indicators_enum.dart index ae37df389..80ccf279a 100644 --- a/lib/pangea/enum/progress_indicators_enum.dart +++ b/lib/pangea/enum/progress_indicators_enum.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -54,4 +55,15 @@ extension ProgressIndicatorsExtension on ProgressIndicatorEnum { 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; + } + } } diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 3b346ac18..0f29d4cab 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.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 /// the process of filtering / sorting / displaying the events. -/// Takes a construct type and a list of events 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 /// key = lemmma + constructType.string, value = ConstructUses - final Map _constructMap = {}; + Map _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 constructList = []; + List _constructList = []; + + /// A map of categories to lists of ConstructUses + Map> _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({ - required this.type, required List uses, }) { updateConstructs(uses); @@ -27,11 +45,10 @@ class ConstructListModel { /// 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 newUses) { - final List filteredUses = newUses - .where((use) => use.constructType == type || type == null) - .toList(); - _updateConstructMap(filteredUses); + _updateConstructMap(newUses); _updateConstructList(); + _updateCategoriesToUses(); + _updateMetrics(); } /// 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 void _updateConstructList() { // TODO check how expensive this is - constructList = _constructMap.values.toList(); - constructList.sort((a, b) { + _constructList = _constructMap.values.toList(); + _constructList.sort((a, b) { final comp = b.uses.length.compareTo(a.uses.length); if (comp != 0) return comp; return a.lemma.compareTo(b.lemma); }); } - ConstructUses? getConstructUses(ConstructIdentifier identifier) { - return _constructMap[identifier.string]; + void _updateCategoriesToUses() { + _categoriesToUses = {}; + for (final use in constructList()) { + _categoriesToUses[use.category] ??= []; + _categoriesToUses[use.category]!.add(use); + } } - List get constructListWithPoints => - constructList.where((constructUse) => constructUse.points > 0).toList(); + void _updateMetrics() { + vocabLemmas = constructList(type: ConstructTypeEnum.vocab) + .map((e) => e.lemma) + .toSet() + .length; - /// All unique lemmas used in the construct events with non-zero points - List get lemmasWithPoints => - constructListWithPoints.map((e) => e.lemma).toSet().toList(); + grammarLemmas = constructList(type: ConstructTypeEnum.morph) + .map((e) => e.lemma) + .toSet() + .length; - Map> get categoriesToUses { - final Map> categoriesMap = {}; - for (final use in constructListWithPoints) { - categoriesMap[use.category] ??= []; - categoriesMap[use.category]!.add(use); - } - return categoriesMap; + prevXP = totalXP; + totalXP = _constructList.fold( + 0, + (total, construct) => total + construct.points, + ); + level = 1 + sqrt((1 + 8 * totalXP / 100) / 2).floor(); } - int get maxXPPerLemma => - type?.maxXPPerLemma ?? ConstructTypeEnum.vocab.maxXPPerLemma; + ConstructUses? getConstructUses(ConstructIdentifier identifier) { + return _constructMap[identifier.string]; + } - /// The total number of points for all uses of this construct type - int get points { - int totalPoints = 0; - for (final constructUse in _constructMap.values.toList()) { - totalPoints += constructUse.points; - } - return totalPoints; + List constructList({ConstructTypeEnum? type}) => _constructList + .where( + (constructUse) => + constructUse.points > 0 && + (type == null || constructUse.constructType == type), + ) + .toList(); + + Map> 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), + ); } } diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 0e3e3a0b6..b6b13399b 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -142,7 +142,7 @@ class OneConstructUse { ConstructIdentifier get identifier => ConstructIdentifier( lemma: lemma!, type: constructType, - category: category, + category: category ?? "", ); } diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index e5048400d..d0b7bdb73 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -12,12 +12,12 @@ import 'package:sentry_flutter/sentry_flutter.dart'; class ConstructIdentifier { final String lemma; final ConstructTypeEnum type; - final String? category; + final String category; ConstructIdentifier({ required this.lemma, required this.type, - this.category, + required this.category, }); factory ConstructIdentifier.fromJson(Map json) { @@ -37,7 +37,7 @@ class ConstructIdentifier { type: ConstructTypeEnum.values.firstWhere( (e) => e.string == json['type'], ), - category: category, + category: category ?? "", ); } catch (e, s) { debugger(when: kDebugMode); @@ -70,7 +70,7 @@ class ConstructIdentifier { } String get string => - "$lemma-${type.string}${category != null ? "-$category" : "-other"}"; + "$lemma-${type.string}${category != "" ? "-$category" : "-other"}"; } class CandidateMessage { diff --git a/lib/pangea/widgets/animations/gain_points.dart b/lib/pangea/widgets/animations/gain_points.dart index c41b048bf..e3c47f64d 100644 --- a/lib/pangea/widgets/animations/gain_points.dart +++ b/lib/pangea/widgets/animations/gain_points.dart @@ -29,8 +29,10 @@ class PointsGainedAnimationState extends State late Animation _fadeAnimation; StreamSubscription? _pointsSubscription; - int? get _prevXP => MatrixState.pangeaController.getAnalytics.prevXP; - int? get _currentXP => MatrixState.pangeaController.getAnalytics.currentXP; + int? get _prevXP => + MatrixState.pangeaController.getAnalytics.constructListModel.prevXP; + int? get _currentXP => + MatrixState.pangeaController.getAnalytics.constructListModel.totalXP; int? _addedPoints; @override diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart index 16dd586eb..38605e53e 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart @@ -1,18 +1,18 @@ +import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/construct_type_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/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_gen/gen_l10n/l10n.dart'; class AnalyticsPopup extends StatefulWidget { - final ProgressIndicatorEnum indicator; - final ConstructListModel constructsModel; + final ConstructTypeEnum type; final bool showGroups; const AnalyticsPopup({ - required this.indicator, - required this.constructsModel, + required this.type, this.showGroups = true, super.key, }); @@ -23,9 +23,14 @@ class AnalyticsPopup extends StatefulWidget { class AnalyticsPopupState extends State { String? selectedCategory; + ConstructListModel get _constructsModel => + MatrixState.pangeaController.getAnalytics.constructListModel; - List>> get categoriesToUses { - final entries = widget.constructsModel.categoriesToUses.entries.toList(); + Map> get _categoriesToUses => + _constructsModel.categoriesToUses(type: widget.type); + + List>> get _sortedEntries { + final entries = _categoriesToUses.entries.toList(); // Sort the list with custom logic entries.sort((a, b) { // Check if one of the keys is 'Other' @@ -51,7 +56,7 @@ class AnalyticsPopupState extends State { }); String categoryCopy(category) => - widget.constructsModel.type?.getDisplayCopy( + widget.type.getDisplayCopy( category, context, ) ?? @@ -61,10 +66,9 @@ class AnalyticsPopupState extends State { Widget build(BuildContext context) { Widget? dialogContent; final bool hasNoData = - widget.constructsModel.constructListWithPoints.isEmpty; - final bool hasNoCategories = - widget.constructsModel.categoriesToUses.length == 1 && - widget.constructsModel.categoriesToUses.keys.first == "Other"; + _constructsModel.constructList(type: widget.type).isEmpty; + final bool hasNoCategories = _categoriesToUses.length == 1 && + _categoriesToUses.entries.first.key == "Other"; if (selectedCategory != null) { dialogContent = Column( @@ -75,7 +79,7 @@ class AnalyticsPopupState extends State { ), Expanded( child: ConstructsTileList( - widget.constructsModel.categoriesToUses[selectedCategory]!, + _categoriesToUses[selectedCategory]!, ), ), ], @@ -84,13 +88,17 @@ class AnalyticsPopupState extends State { dialogContent = Center(child: Text(L10n.of(context)!.noDataFound)); } else if (hasNoCategories || !widget.showGroups) { 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 { dialogContent = ListView.builder( - itemCount: categoriesToUses.length, + itemCount: _sortedEntries.length, itemBuilder: (context, index) { - final category = categoriesToUses[index]; + final category = _sortedEntries[index]; return Column( children: [ ListTile( @@ -115,7 +123,7 @@ class AnalyticsPopupState extends State { borderRadius: BorderRadius.circular(20.0), child: Scaffold( appBar: AppBar( - title: Text(widget.indicator.tooltip(context)), + title: Text(widget.type.indicator.tooltip(context)), leading: IconButton( icon: selectedCategory == null ? const Icon(Icons.close) diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart new file mode 100644 index 000000000..108195833 --- /dev/null +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart @@ -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, + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index 53e9ae172..29ce30078 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -2,18 +2,15 @@ import 'dart:async'; import 'package:fluffychat/pages/chat_list/client_chooser_button.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/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/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/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/flag.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// 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 /// be clicked to access more fine-grained analytics data. class LearningProgressIndicators extends StatefulWidget { - const LearningProgressIndicators({ - super.key, - }); + const LearningProgressIndicators({super.key}); @override - LearningProgressIndicatorsState createState() => + State createState() => LearningProgressIndicatorsState(); } class LearningProgressIndicatorsState extends State { - final PangeaController _pangeaController = MatrixState.pangeaController; + ConstructListModel get _constructsModel => + MatrixState.pangeaController.getAnalytics.constructListModel; + bool _loading = true; - /// A stream subscription to listen for updates to - /// the analytics data, either locally or from events - StreamSubscription? _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 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; + StreamSubscription? _analyticsSubscription; @override void initState() { super.initState(); - updateAnalyticsData( - _pangeaController.getAnalytics.analyticsStream.value?.constructs ?? [], - ); - _analyticsUpdateSubscription = _pangeaController - .getAnalytics.analyticsStream.stream - .listen((update) => updateAnalyticsData(update.constructs)); + _analyticsSubscription = MatrixState + .pangeaController.getAnalytics.analyticsStream.stream + .listen(updateData); } @override void dispose() { - _analyticsUpdateSubscription?.cancel(); + _analyticsSubscription?.cancel(); + _analyticsSubscription = null; super.dispose(); } - /// Update the analytics data shown in the UI. This comes from a - /// combination of stored events and locally cached data. - Future updateAnalyticsData(List constructs) async { - currentConstructs = constructs; - if (loading) loading = false; + void updateData(AnalyticsStreamUpdate _) { + if (_loading) _loading = false; if (mounted) setState(() {}); } - /// Get the number of points for a given progress indicator - ConstructListModel? getConstructsModel(ProgressIndicatorEnum indicator) { + int uniqueLemmas(ProgressIndicatorEnum indicator) { switch (indicator) { - case ProgressIndicatorEnum.wordsUsed: - return _pangeaController.getAnalytics.vocabModel; case ProgressIndicatorEnum.morphsUsed: - return _pangeaController.getAnalytics.grammarModel; - default: - return null; - } - } - - /// Get the number of points for a given progress indicator - int? getProgressPoints(ProgressIndicatorEnum indicator) { - switch (indicator) { + return _constructsModel.grammarLemmas; case ProgressIndicatorEnum.wordsUsed: - return _pangeaController - .getAnalytics.vocabModel.lemmasWithPoints.length; - case ProgressIndicatorEnum.morphsUsed: - return _pangeaController - .getAnalytics.grammarModel.lemmasWithPoints.length; - case ProgressIndicatorEnum.level: - return level; + return _constructsModel.vocabLemmas; + default: + return 0; } } - // 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 Widget build(BuildContext context) { if (Matrix.of(context).client.userID == null) { 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( children: [ const ClientChooserButton(), @@ -180,22 +87,19 @@ class LearningProgressIndicatorsState (indicator) => Padding( padding: const EdgeInsets.only(right: 10), child: ProgressIndicatorBadge( - points: getProgressPoints(indicator), + points: uniqueLemmas(indicator), + loading: _loading, onTap: () { - final model = getConstructsModel(indicator); - if (model == null) return; showDialog( context: context, builder: (c) => AnalyticsPopup( - indicator: indicator, - constructsModel: model, + type: indicator.constructType, showGroups: indicator == ProgressIndicatorEnum.morphsUsed, ), ); }, - progressIndicator: indicator, - loading: loading, + indicator: indicator, ), ), ) @@ -207,7 +111,8 @@ class LearningProgressIndicatorsState builder: (c) => const SettingsLearning(), ), child: LanguageFlag( - language: _pangeaController.languageController.userL2, + language: MatrixState + .pangeaController.languageController.userL2, size: 24, ), ), @@ -219,8 +124,17 @@ class LearningProgressIndicatorsState child: Stack( alignment: Alignment.center, children: [ - Positioned(left: 16, right: 0, child: progressBar), - Positioned(left: 0, child: levelBadge), + Positioned( + left: 16, + right: 0, + child: LearningProgressBar( + totalXP: _constructsModel.totalXP, + ), + ), + Positioned( + left: 0, + child: LevelBadge(level: _constructsModel.level), + ), ], ), ), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/level_badge.dart b/lib/pangea/widgets/chat_list/analytics_summary/level_badge.dart new file mode 100644 index 000000000..005da0ad4 --- /dev/null +++ b/lib/pangea/widgets/chat_list/analytics_summary/level_badge.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart index f05ac634c..537826956 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart @@ -3,36 +3,36 @@ import 'package:flutter/material.dart'; /// A badge that represents one learning progress indicator (i.e., construct uses) class ProgressIndicatorBadge extends StatelessWidget { - final int? points; - final VoidCallback onTap; - final ProgressIndicatorEnum progressIndicator; final bool loading; + final int points; + final VoidCallback onTap; + final ProgressIndicatorEnum indicator; const ProgressIndicatorBadge({ super.key, - required this.points, required this.onTap, - required this.progressIndicator, + required this.indicator, required this.loading, + required this.points, }); @override Widget build(BuildContext context) { return Tooltip( - message: progressIndicator.tooltip(context), + message: indicator.tooltip(context), child: InkWell( onTap: onTap, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - progressIndicator.icon, - color: progressIndicator.color(context), + indicator.icon, + color: indicator.color(context), ), const SizedBox(width: 5), !loading ? Text( - points?.toString() ?? '0', + points.toString(), style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, diff --git a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart index c659490b3..82d995ad6 100644 --- a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart +++ b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart @@ -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/practice_activities.dart/message_activity_request.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; /// Seperated out the target tokens from the practice activity card @@ -26,12 +25,12 @@ class TargetTokensController { _targetTokens = await _initialize(pangeaMessageEvent); - final allConstructs = MatrixState - .pangeaController.getAnalytics.analyticsStream.value?.constructs; - await updateTokensWithConstructs( - allConstructs ?? [], - pangeaMessageEvent, - ); + // final allConstructs = MatrixState + // .pangeaController.getAnalytics.analyticsStream.value?.constructs; + // await updateTokensWithConstructs( + // allConstructs ?? [], + // pangeaMessageEvent, + // ); return _targetTokens!; } @@ -60,7 +59,7 @@ class TargetTokensController { ) async { final ConstructListModel constructList = ConstructListModel( uses: constructUses, - type: null, + // type: null, ); _targetTokens ??= await _initialize(pangeaMessageEvent); @@ -76,6 +75,7 @@ class TargetTokensController { ConstructIdentifier( lemma: construct.id.lemma, type: construct.id.type, + category: construct.id.category, ), ); if (constructUseModel != null) {