From 4ede7c9bdd8469ac8e0adbc2d9f932f157c6f3c8 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 30 Jul 2024 16:44:12 -0400 Subject: [PATCH] store new construct uses locally. use a combination of those and stored analytics events to update mini analytics UI. --- lib/pages/chat/chat.dart | 10 +- .../widgets/start_igc_button.dart | 4 +- .../controllers/get_analytics_controller.dart | 212 ++++++++++++++++++ .../controllers/my_analytics_controller.dart | 157 ++++++------- lib/pangea/controllers/pangea_controller.dart | 8 +- .../analytics/construct_list_model.dart | 50 +++++ lib/pangea/models/choreo_record.dart | 5 +- .../pages/analytics/construct_list.dart | 16 +- .../learning_progress_indicators.dart | 144 +++++++----- .../analytics_summary/progress_indicator.dart | 49 +++- 10 files changed, 499 insertions(+), 156 deletions(-) create mode 100644 lib/pangea/controllers/get_analytics_controller.dart create mode 100644 lib/pangea/models/analytics/construct_list_model.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index a6eb9d43e..bf142b185 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -636,7 +636,15 @@ class ChatController extends State // analytics based on when / how many messages the logged in user send. This // stream sends the data for newly sent messages. if (msgEventId != null) { - pangeaController.myAnalytics.setState(data: {'eventID': msgEventId}); + pangeaController.myAnalytics.setState( + data: { + 'eventID': msgEventId, + 'roomID': room.id, + 'originalSent': originalSent, + 'tokensSent': tokensSent, + 'choreo': choreo, + }, + ); } if (previousEdit != null) { diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart index e8625da95..08bcdf839 100644 --- a/lib/pangea/choreographer/widgets/start_igc_button.dart +++ b/lib/pangea/choreographer/widgets/start_igc_button.dart @@ -50,7 +50,9 @@ class StartIGCButtonState extends State _controller?.stop(); _controller?.reverse(); } - setState(() => prevState = assistanceState); + if (mounted) { + setState(() => prevState = assistanceState); + } } bool get itEnabled => widget.controller.choreographer.itEnabled; diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart new file mode 100644 index 000000000..1de8c8f15 --- /dev/null +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -0,0 +1,212 @@ +import 'package:fluffychat/pangea/constants/class_default_values.dart'; +import 'package:fluffychat/pangea/constants/local.key.dart'; +import 'package:fluffychat/pangea/constants/match_rule_ids.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_event.dart'; +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:sentry_flutter/sentry_flutter.dart'; + +/// A minimized version of AnalyticsController that get the logged in user's analytics +class GetAnalyticsController { + late PangeaController _pangeaController; + final List _cache = []; + + GetAnalyticsController(PangeaController pangeaController) { + _pangeaController = pangeaController; + } + + String? get l2Code => _pangeaController.languageController.userL2?.langCode; + + // A local cache of eventIds and construct uses for messages sent since the last update + Map> get messagesSinceUpdate { + try { + final dynamic locallySaved = _pangeaController.pStoreService.read( + PLocalKey.messagesSinceUpdate, + ); + if (locallySaved == null) { + _pangeaController.myAnalytics.setMessagesSinceUpdate({}); + return {}; + } + try { + // try to get the local cache of messages and format them as OneConstructUses + final Map> cache = + Map>.from(locallySaved); + final Map> formattedCache = {}; + for (final entry in cache.entries) { + formattedCache[entry.key] = + entry.value.map((e) => OneConstructUse.fromJson(e)).toList(); + } + return formattedCache; + } catch (err) { + // if something goes wrong while trying to format the local data, clear it + _pangeaController.myAnalytics.setMessagesSinceUpdate({}); + return {}; + } + } catch (exception, stackTrace) { + ErrorHandler.logError( + e: PangeaWarningError( + "Failed to get messages since update: $exception", + ), + s: stackTrace, + m: 'Failed to retrieve messages since update', + ); + return {}; + } + } + + /// Get a list of all the construct analytics events + /// for the logged in user in their current L2 + Future?> getConstructs({ + bool forceUpdate = false, + ConstructTypeEnum? constructType, + }) async { + debugPrint("getting constructs"); + await _pangeaController.matrixState.client.roomsLoading; + + final DateTime? lastUpdated = await myAnalyticsLastUpdated(); + final List? local = getConstructsLocal( + constructType: constructType, + lastUpdated: lastUpdated, + ); + if (local != null && !forceUpdate) { + debugPrint("returning local constructs"); + return local; + } + debugPrint("fetching new constructs"); + + final unfilteredConstructs = await allMyConstructs(); + final filteredConstructs = await filterConstructs( + unfilteredConstructs: unfilteredConstructs, + ); + + if (local == null) { + cacheConstructs( + constructType: constructType, + events: filteredConstructs, + ); + } + + return filteredConstructs; + } + + /// Get the last time the user updated their analytics for their current l2 + Future myAnalyticsLastUpdated() async { + if (l2Code == null) return null; + final Room? analyticsRoom = + _pangeaController.matrixState.client.analyticsRoomLocal(l2Code!); + if (analyticsRoom == null) return null; + final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated( + _pangeaController.matrixState.client.userID!, + ); + return lastUpdated; + } + + /// Get the cached construct analytics events for the current user, if it exists + List? getConstructsLocal({ + DateTime? lastUpdated, + ConstructTypeEnum? constructType, + }) { + final index = _cache.indexWhere( + (e) => e.type == constructType && e.langCode == l2Code, + ); + + if (index > -1) { + if (_cache[index].needsUpdate(lastUpdated)) { + _cache.removeAt(index); + return null; + } + return _cache[index].events; + } + + return null; + } + + /// Get all the construct analytics events for the logged in user + Future> allMyConstructs() async { + if (l2Code == null) return []; + final Room? analyticsRoom = + _pangeaController.matrixState.client.analyticsRoomLocal(l2Code!); + if (analyticsRoom == null) return []; + + return await analyticsRoom.getAnalyticsEvents( + userId: _pangeaController.matrixState.client.userID!, + ) ?? + []; + } + + /// Filter out constructs that are not relevant to the user, specifically those from + /// rooms in which the user is a teacher and those that are interative translation span constructs + Future> filterConstructs({ + required List unfilteredConstructs, + }) async { + final List adminSpaceRooms = + await _pangeaController.matrixState.client.teacherRoomIds; + for (final construct in unfilteredConstructs) { + construct.content.uses.removeWhere( + (use) { + if (adminSpaceRooms.contains(use.chatId)) { + return true; + } + return use.lemma == "Try interactive translation" || + use.lemma == "itStart" || + use.lemma == MatchRuleIds.interactiveTranslation; + }, + ); + } + unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty); + return unfilteredConstructs; + } + + /// Cache the construct analytics events for the current user + void cacheConstructs({ + required List events, + ConstructTypeEnum? constructType, + }) { + if (l2Code == null) return; + final entry = AnalyticsCacheEntry( + type: constructType, + events: List.from(events), + langCode: l2Code!, + ); + _cache.add(entry); + } +} + +class AnalyticsCacheEntry { + final String langCode; + final ConstructTypeEnum? type; + final List events; + late final DateTime _createdAt; + + AnalyticsCacheEntry({ + required this.langCode, + required this.type, + required this.events, + }) { + _createdAt = DateTime.now(); + } + + bool get isExpired => + DateTime.now().difference(_createdAt).inMinutes > + ClassDefaultValues.minutesDelayToMakeNewChartAnalytics; + + 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); + } +} diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 20c9a2803..0a1048c72 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -10,16 +10,19 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_e import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/representation_content_model.dart'; +import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; /// handles the processing of analytics for /// 1) messages sent by the user and /// 2) constructs used by the user, both in sending messages and doing practice activities class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; + final StreamController analyticsUpdateStream = StreamController.broadcast(); Timer? _updateTimer; /// the max number of messages that will be cached before @@ -38,10 +41,8 @@ class MyAnalyticsController extends BaseController { // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user - stateStream - .where((data) => data is Map && data.containsKey("eventID")) - .listen((data) { - updateAnalyticsTimer(data['eventID']); + stateStream.where((data) => data is Map).listen((data) { + onMessageSent(data as Map); }); } @@ -67,11 +68,9 @@ class MyAnalyticsController extends BaseController { Client get _client => _pangeaController.matrixState.client; - /// Given an newly sent message, reset the timer - /// and add the event ID to the cache of un-added event IDs - void updateAnalyticsTimer(String newEventId) { - addMessageSinceUpdate(newEventId); - + /// Given the data from a newly sent message, format and cache + /// the message's construct data locally and reset the update timer + void onMessageSent(Map data) { // cancel the last timer that was set on message event and // reset it to fire after _minutesBeforeUpdate minutes _updateTimer?.cancel(); @@ -79,97 +78,88 @@ class MyAnalyticsController extends BaseController { debugPrint("timer fired, updating analytics"); updateAnalytics(); }); + + // extract the relevant data about this message + final String? eventID = data['eventID']; + final String? roomID = data['roomID']; + final PangeaRepresentation? originalSent = data['originalSent']; + final PangeaMessageTokens? tokensSent = data['tokensSent']; + final ChoreoRecord? choreo = data['choreo']; + + if (roomID == null || eventID == null) return; + + // convert that data into construct uses and add it to the cache + final metadata = ConstructUseMetaData( + roomId: roomID, + eventId: eventID, + timeStamp: DateTime.now(), + ); + + final grammarConstructs = choreo?.grammarConstructUses(metadata: metadata); + final itConstructs = choreo?.itStepsToConstructUses(metadata: metadata); + final vocabUses = tokensSent != null + ? originalSent?.vocabUses( + choreo: choreo, + tokens: tokensSent.tokens, + metadata: metadata, + ) + : null; + final List constructs = [ + ...(grammarConstructs ?? []), + ...(itConstructs ?? []), + ...(vocabUses ?? []), + ]; + addMessageSinceUpdate( + eventID, + constructs, + ); } - // adds an event ID to the cache of un-added event IDs - // if the event IDs isn't already added - void addMessageSinceUpdate(String eventId) { + /// Add a list of construct uses for a new message to the local + /// cache of recently sent messages + void addMessageSinceUpdate( + String eventID, + List constructs, + ) { try { - final List currentCache = messagesSinceUpdate; - if (!currentCache.contains(eventId)) { - currentCache.add(eventId); - _pangeaController.pStoreService.save( - PLocalKey.messagesSinceUpdate, - currentCache, - ); + final currentCache = _pangeaController.analytics.messagesSinceUpdate; + if (!currentCache.containsKey(eventID)) { + currentCache[eventID] = constructs; + setMessagesSinceUpdate(currentCache); } // if the cached has reached if max-length, update analytics - if (messagesSinceUpdate.length > _maxMessagesCached) { + if (_pangeaController.analytics.messagesSinceUpdate.length > + _maxMessagesCached) { debugPrint("reached max messages, updating"); updateAnalytics(); } - } catch (exception, stackTrace) { + } catch (e, s) { ErrorHandler.logError( - e: PangeaWarningError("Failed to add message since update: $exception"), - s: stackTrace, - m: 'Failed to add message since update for eventId: $eventId', - ); - Sentry.captureException( - exception, - stackTrace: stackTrace, - withScope: (scope) { - scope.setExtra( - 'extra_info', - 'Failed during addMessageSinceUpdate with eventId: $eventId', - ); - scope.setTag('where', 'addMessageSinceUpdate'); - }, + e: PangeaWarningError("Failed to add message since update: $e"), + s: s, + m: 'Failed to add message since update for eventId: $eventID', ); } } - // called before updating analytics + /// Clears the local cache of recently sent constructs. Called before updating analytics void clearMessagesSinceUpdate() { - _pangeaController.pStoreService.save( - PLocalKey.messagesSinceUpdate, - [], - ); + setMessagesSinceUpdate({}); } - // a local cache of eventIds for messages sent since the last update - // it's possible for this cache to be invalid or deleted - // It's a proxy measure for messages sent since last update - List get messagesSinceUpdate { - try { - Logs().d('Reading messages since update from local storage'); - final dynamic locallySaved = _pangeaController.pStoreService.read( - PLocalKey.messagesSinceUpdate, - ); - if (locallySaved == null) { - Logs().d('No locally saved messages found, initializing empty list.'); - _pangeaController.pStoreService.save( - PLocalKey.messagesSinceUpdate, - [], - ); - return []; - } - return locallySaved.cast(); - } catch (exception, stackTrace) { - ErrorHandler.logError( - e: PangeaWarningError( - "Failed to get messages since update: $exception", - ), - s: stackTrace, - m: 'Failed to retrieve messages since update', - ); - Sentry.captureException( - exception, - stackTrace: stackTrace, - withScope: (scope) { - scope.setExtra( - 'extra_info', - 'Error during messagesSinceUpdate getter', - ); - scope.setTag('where', 'messagesSinceUpdate'); - }, - ); - _pangeaController.pStoreService.save( - PLocalKey.messagesSinceUpdate, - [], - ); - return []; + /// Save the local cache of recently sent constructs to the local storage + void setMessagesSinceUpdate(Map> cache) { + final formattedCache = {}; + for (final entry in cache.entries) { + final constructJsons = entry.value.map((e) => e.toJson()).toList(); + formattedCache[entry.key] = constructJsons; } + _pangeaController.pStoreService.save( + PLocalKey.messagesSinceUpdate, + formattedCache, + ); + analyticsUpdateStream.add(null); } Completer? _updateCompleter; @@ -182,6 +172,7 @@ class MyAnalyticsController extends BaseController { try { await _updateAnalytics(); clearMessagesSinceUpdate(); + analyticsUpdateStream.add(null); } catch (err, s) { ErrorHandler.logError( e: err, diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index 79222bbed..a62ec0428 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/class_controller.dart'; import 'package:fluffychat/pangea/controllers/contextual_definition_controller.dart'; +import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/language_controller.dart'; import 'package:fluffychat/pangea/controllers/language_detection_controller.dart'; import 'package:fluffychat/pangea/controllers/language_list_controller.dart'; @@ -35,7 +36,6 @@ import '../../config/app_config.dart'; import '../utils/firebase_analytics.dart'; import '../utils/p_store.dart'; import 'it_feedback_controller.dart'; -import 'message_analytics_controller.dart'; class PangeaController { ///pangeaControllers @@ -43,7 +43,8 @@ class PangeaController { late LanguageController languageController; late ClassController classController; late PermissionsController permissionsController; - late AnalyticsController analytics; + // late AnalyticsController analytics; + late GetAnalyticsController analytics; late MyAnalyticsController myAnalytics; late WordController wordNet; late MessageDataController messageData; @@ -91,7 +92,8 @@ class PangeaController { languageController = LanguageController(this); classController = ClassController(this); permissionsController = PermissionsController(this); - analytics = AnalyticsController(this); + // analytics = AnalyticsController(this); + analytics = GetAnalyticsController(this); myAnalytics = MyAnalyticsController(this); messageData = MessageDataController(this); wordNet = WordController(this); diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart new file mode 100644 index 000000000..a19fb1676 --- /dev/null +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -0,0 +1,50 @@ +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; + +/// 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 { + ConstructTypeEnum type; + List uses; + + ConstructListModel({ + required this.type, + required this.uses, + }); + + /// All unique lemmas used in the construct events + List get lemmas => constructs.map((e) => e.lemma).toSet().toList(); + + /// A list of ConstructUses, each of which contains a lemma and + /// a list of uses, sorted by the number of uses + List get constructs { + final List filtered = + uses.where((use) => use.constructType == type).toList(); + + final Map> lemmaToUses = {}; + for (final use in filtered) { + if (use.lemma == null) continue; + lemmaToUses[use.lemma!] ??= []; + lemmaToUses[use.lemma!]!.add(use); + } + + final constructUses = lemmaToUses.entries + .map( + (entry) => ConstructUses( + lemma: entry.key, + uses: entry.value, + constructType: type, + ), + ) + .toList(); + + constructUses.sort((a, b) { + final comp = b.uses.length.compareTo(a.uses.length); + if (comp != 0) return comp; + return a.lemma.compareTo(b.lemma); + }); + + return constructUses; + } +} diff --git a/lib/pangea/models/choreo_record.dart b/lib/pangea/models/choreo_record.dart index 64ed4741f..6fdde333a 100644 --- a/lib/pangea/models/choreo_record.dart +++ b/lib/pangea/models/choreo_record.dart @@ -118,7 +118,10 @@ class ChoreoRecord { String get finalMessage => choreoSteps.isNotEmpty ? choreoSteps.last.text : ""; - /// get construct uses of type grammar for the message + /// Get construct uses of type grammar for the message from this ChoreoRecord. + /// Takes either an event (typically when the Representation itself is + /// available) or construct use metadata (when the event is not available, + /// i.e. immediately after message send) to create the construct uses. List grammarConstructUses({ Event? event, ConstructUseMetaData? metadata, diff --git a/lib/pangea/pages/analytics/construct_list.dart b/lib/pangea/pages/analytics/construct_list.dart index 0f06ddc8d..9d032293d 100644 --- a/lib/pangea/pages/analytics/construct_list.dart +++ b/lib/pangea/pages/analytics/construct_list.dart @@ -110,11 +110,7 @@ class ConstructListViewState extends State { widget.pangeaController.analytics .getConstructs( constructType: constructType, - removeIT: true, - defaultSelected: widget.defaultSelected, - selected: widget.selected, forceUpdate: true, - timeSpan: widget.timeSpan, ) .whenComplete(() => setState(() => fetchingConstructs = false)) .then((value) => setState(() => _constructs = value)); @@ -126,11 +122,7 @@ class ConstructListViewState extends State { widget.pangeaController.analytics .getConstructs( constructType: constructType, - removeIT: true, - defaultSelected: widget.defaultSelected, - selected: widget.selected, forceUpdate: true, - timeSpan: widget.timeSpan, ) .then( (value) => setState(() { @@ -163,11 +155,11 @@ class ConstructListViewState extends State { ) async { final Client client = Matrix.of(context).client; PangeaMessageEvent msgEvent; - if (_msgEventCache.containsKey(use.msgId!)) { - return _msgEventCache[use.msgId!]!; + if (_msgEventCache.containsKey(use.msgId)) { + return _msgEventCache[use.msgId]!; } final Room? msgRoom = use.getRoom(client); - if (msgRoom == null || use.msgId == null) { + if (msgRoom == null) { return null; } @@ -189,7 +181,7 @@ class ConstructListViewState extends State { timeline: timeline, ownMessage: event.senderId == client.userID, ); - _msgEventCache[use.msgId!] = msgEvent; + _msgEventCache[use.msgId] = msgEvent; return msgEvent; } 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 ae6b13e30..5a30b9d0d 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 @@ -1,10 +1,12 @@ +import 'dart:async'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/enum/time_span.dart'; -import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; @@ -30,64 +32,78 @@ class LearningProgressIndicators extends StatefulWidget { class LearningProgressIndicatorsState extends State { final PangeaController _pangeaController = MatrixState.pangeaController; - int? wordsUsed; - int? errorTypes; + + /// A stream subscription to listen for updates to + /// the analytics data, either locally or from events + StreamSubscription? _onAnalyticsUpdate; + + /// Vocabulary constructs model + ConstructListModel? words; + + /// Grammar constructs model + ConstructListModel? errors; @override void initState() { super.initState(); - setData(); + updateAnalyticsData(); + // listen for changes to analytics data and update the UI + _onAnalyticsUpdate = _pangeaController + .myAnalytics.analyticsUpdateStream.stream + .listen((_) => updateAnalyticsData()); } - AnalyticsSelected get defaultSelected => AnalyticsSelected( - _pangeaController.matrixState.client.userID!, - AnalyticsEntryType.student, - "", - ); - - Future setData() async { - await getNumLemmasUsed(); - setState(() {}); + @override + void dispose() { + _onAnalyticsUpdate?.cancel(); + super.dispose(); } - Future getNumLemmasUsed() async { - final constructs = await _pangeaController.analytics.getConstructs( - defaultSelected: defaultSelected, - timeSpan: TimeSpan.forever, - ); - if (constructs == null) { - errorTypes = 0; - wordsUsed = 0; - return; + /// Update the analytics data shown in the UI. This comes from a + /// combination of stored events and locally cached data. + Future updateAnalyticsData() async { + final constructEvents = await _pangeaController.analytics.getConstructs(); + final List localUses = []; + for (final uses in _pangeaController.analytics.messagesSinceUpdate.values) { + localUses.addAll(uses); } - final List errorLemmas = []; - final List vocabLemmas = []; - for (final event in constructs) { - for (final use in event.content.uses) { - if (use.lemma == null) continue; - switch (use.constructType) { - case ConstructTypeEnum.grammar: - errorLemmas.add(use.lemma!); - break; - case ConstructTypeEnum.vocab: - vocabLemmas.add(use.lemma!); - break; - default: - break; - } - } + if (constructEvents == null || constructEvents.isEmpty) { + words = ConstructListModel( + type: ConstructTypeEnum.vocab, + uses: localUses, + ); + errors = ConstructListModel( + type: ConstructTypeEnum.grammar, + uses: localUses, + ); + return; } - errorTypes = errorLemmas.toSet().length; - wordsUsed = vocabLemmas.toSet().length; + + final List storedConstruct = + constructEvents.expand((e) => e.content.uses).toList(); + final List allConstructs = [ + ...storedConstruct, + ...localUses, + ]; + + words = ConstructListModel( + type: ConstructTypeEnum.vocab, + uses: allConstructs, + ); + errors = ConstructListModel( + type: ConstructTypeEnum.grammar, + uses: allConstructs, + ); + setState(() {}); } int? getProgressPoints(ProgressIndicatorEnum indicator) { switch (indicator) { case ProgressIndicatorEnum.wordsUsed: - return wordsUsed; + return words?.lemmas.length; case ProgressIndicatorEnum.errorTypes: - return errorTypes; + return errors?.lemmas.length; case ProgressIndicatorEnum.level: return level; } @@ -95,8 +111,8 @@ class LearningProgressIndicatorsState int get xpPoints { final points = [ - wordsUsed ?? 0, - errorTypes ?? 0, + words?.lemmas.length ?? 0, + errors?.lemmas.length ?? 0, ]; return points.reduce((a, b) => a + b); } @@ -161,14 +177,36 @@ class LearningProgressIndicatorsState children: [ SizedBox( width: FluffyThemes.columnWidth - (36 * 2) - 25, - child: LinearProgressIndicator( - value: (xpPoints % 100) / 100, - color: Theme.of(context).colorScheme.primary, - backgroundColor: - Theme.of(context).colorScheme.onPrimary, - minHeight: 15, - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), + child: Expanded( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Container( + height: 15, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + color: + Theme.of(context).colorScheme.onPrimary, + ), + ), + AnimatedContainer( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + height: 15, + width: + (FluffyThemes.columnWidth - (36 * 2) - 25) * + ((xpPoints % 100) / 100), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), ), ), ], 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 93e932aa2..9fc9bb55c 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:flutter/material.dart'; @@ -33,8 +34,8 @@ class ProgressIndicatorBadge extends StatelessWidget { ), const SizedBox(width: 5), points != null - ? Text( - points.toString(), + ? AnimatedCount( + count: points!, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -49,3 +50,47 @@ class ProgressIndicatorBadge extends StatelessWidget { ); } } + +class AnimatedCount extends ImplicitlyAnimatedWidget { + const AnimatedCount({ + super.key, + required this.count, + this.style, + super.duration = const Duration(seconds: 1), + super.curve = FluffyThemes.animationCurve, + }); + + final int count; + final TextStyle? style; + + @override + ImplicitlyAnimatedWidgetState createState() { + return _AnimatedCountState(); + } +} + +class _AnimatedCountState extends AnimatedWidgetBaseState { + IntTween _intCount = IntTween(begin: 0, end: 1); + + @override + void initState() { + super.initState(); + _intCount = IntTween(begin: 0, end: widget.count.toInt()); + controller.forward(); + } + + @override + Widget build(BuildContext context) { + final String text = _intCount.evaluate(animation).toString(); + return Text(text, style: widget.style); + } + + @override + void forEachTween(TweenVisitor visitor) { + _intCount = visitor( + _intCount, + widget.count, + (dynamic value) => IntTween(begin: value), + ) as IntTween; + } +}