From 75234e6a6f3aac3e0b610199f8e0fa3cdce5bd00 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Wed, 14 Aug 2024 09:07:10 -0400 Subject: [PATCH] inital work for having a 2-stage progress bar and saving draft analytics locally --- lib/pages/chat/chat_view.dart | 12 +- lib/pages/chat_list/chat_list.dart | 2 + .../controllers/choreographer.dart | 1 + lib/pangea/choreographer/widgets/it_bar.dart | 47 ++-- lib/pangea/constants/analytics_constants.dart | 3 + .../controllers/get_analytics_controller.dart | 123 +++++++-- .../controllers/my_analytics_controller.dart | 165 +++++++++--- lib/pangea/controllers/pangea_controller.dart | 13 + .../analytics/construct_list_model.dart | 27 +- lib/pangea/models/igc_text_data_model.dart | 49 +--- lib/pangea/models/span_data.dart | 27 +- lib/pangea/utils/logout.dart | 4 - .../widgets/animations/gain_points.dart | 103 ++++++++ .../progress_bar/animated_level_dart.dart | 87 +++++++ .../animations/progress_bar/level_bar.dart | 59 +++++ .../animations/progress_bar/progress_bar.dart | 36 +++ .../progress_bar/progress_bar_background.dart | 30 +++ .../progress_bar/progress_bar_details.dart | 23 ++ .../learning_progress_indicators.dart | 246 +++++++----------- lib/pangea/widgets/igc/span_card.dart | 143 +++++----- lib/pangea/widgets/igc/span_data.dart | 184 ------------- .../user_settings/p_language_dialog.dart | 32 ++- 22 files changed, 859 insertions(+), 557 deletions(-) create mode 100644 lib/pangea/constants/analytics_constants.dart create mode 100644 lib/pangea/widgets/animations/gain_points.dart create mode 100644 lib/pangea/widgets/animations/progress_bar/animated_level_dart.dart create mode 100644 lib/pangea/widgets/animations/progress_bar/level_bar.dart create mode 100644 lib/pangea/widgets/animations/progress_bar/progress_bar.dart create mode 100644 lib/pangea/widgets/animations/progress_bar/progress_bar_background.dart create mode 100644 lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart delete mode 100644 lib/pangea/widgets/igc/span_data.dart diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 405e16418..4a7d288d8 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pages/chat/reply_display.dart'; import 'package:fluffychat/pangea/choreographer/widgets/it_bar.dart'; import 'package:fluffychat/pangea/choreographer/widgets/start_igc_button.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/chat_floating_action_button.dart'; import 'package:fluffychat/utils/account_config.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; @@ -466,8 +467,15 @@ class ChatView extends StatelessWidget { StartIGCButton( controller: controller, ), - ChatFloatingActionButton( - controller: controller, + Row( + children: [ + const PointsGainedAnimation( + color: Colors.blue, + ), + ChatFloatingActionButton( + controller: controller, + ), + ], ), ], ), diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index cea2e511a..6148573b8 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -931,6 +931,8 @@ class ChatListController extends State } // #Pangea + MatrixState.pangeaController.myAnalytics.initialize(); + MatrixState.pangeaController.analytics.initialize(); await _initPangeaControllers(client); // Pangea# if (!mounted) return; diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart index 1a11de0e3..bafd1bbef 100644 --- a/lib/pangea/choreographer/controllers/choreographer.dart +++ b/lib/pangea/choreographer/controllers/choreographer.dart @@ -411,6 +411,7 @@ class Choreographer { choreoRecord = ChoreoRecord.newRecord; itController.clear(); igc.clear(); + pangeaController.myAnalytics.clearDraftConstructUses(roomId); // errorService.clear(); _resetDebounceTimer(); } diff --git a/lib/pangea/choreographer/widgets/it_bar.dart b/lib/pangea/choreographer/widgets/it_bar.dart index 28b0f8bd8..536fd4610 100644 --- a/lib/pangea/choreographer/widgets/it_bar.dart +++ b/lib/pangea/choreographer/widgets/it_bar.dart @@ -318,6 +318,26 @@ class ITChoices extends StatelessWidget { ); } + void selectContinuance(int index, BuildContext context) { + final Continuance continuance = + controller.currentITStep!.continuances[index]; + if (continuance.level == 1 || continuance.wasClicked) { + Future.delayed( + const Duration(milliseconds: 500), + () => controller.selectTranslation(index), + ); + } else { + showCard( + context, + index, + continuance.level == 2 ? ChoreoConstants.yellow : ChoreoConstants.red, + continuance.feedbackText(context), + ); + } + controller.currentITStep!.continuances[index].wasClicked = true; + controller.choreographer.setState(); + } + @override Widget build(BuildContext context) { try { @@ -342,31 +362,8 @@ class ITChoices extends StatelessWidget { return Choice(text: "error", color: Colors.red); } }).toList(), - onPressed: (int index) { - final Continuance continuance = - controller.currentITStep!.continuances[index]; - debugPrint("is gold? ${continuance.gold}"); - if (continuance.level == 1 || continuance.wasClicked) { - Future.delayed( - const Duration(milliseconds: 500), - () => controller.selectTranslation(index), - ); - } else { - showCard( - context, - index, - continuance.level == 2 - ? ChoreoConstants.yellow - : ChoreoConstants.red, - continuance.feedbackText(context), - ); - } - controller.currentITStep!.continuances[index].wasClicked = true; - controller.choreographer.setState(); - }, - onLongPress: (int index) { - showCard(context, index); - }, + onPressed: (int index) => selectContinuance(index, context), + onLongPress: (int index) => showCard(context, index), uniqueKeyForLayerLink: (int index) => "itChoices$index", selectedChoiceIndex: null, ); diff --git a/lib/pangea/constants/analytics_constants.dart b/lib/pangea/constants/analytics_constants.dart new file mode 100644 index 000000000..1608ef422 --- /dev/null +++ b/lib/pangea/constants/analytics_constants.dart @@ -0,0 +1,3 @@ +class AnalyticsConstants { + static const int xpPerLevel = 2000; +} diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index 431cf69ee..dff3bee90 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -1,33 +1,122 @@ import 'dart:async'; +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; 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/my_analytics_controller.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/construct_list_model.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: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 class GetAnalyticsController { late PangeaController _pangeaController; final List _cache = []; + StreamSubscription? _analyticsUpdateSubscription; + CachedStreamController> analyticsStream = + CachedStreamController>(); + + /// The previous XP points of the user, before the last update. + /// Used for animating analytics updates. + int? prevXP; GetAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; } String? get l2Code => _pangeaController.languageController.userL2?.langCode; - Client get client => _pangeaController.matrixState.client; - /// A local cache of eventIds and construct uses for messages sent since the last update + 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 + int get level => currentXP ~/ AnalyticsConstants.xpPerLevel; + + void initialize() { + _analyticsUpdateSubscription ??= _pangeaController + .myAnalytics.analyticsUpdateStream.stream + .listen(onAnalyticsUpdate); + + _pangeaController.myAnalytics.lastUpdatedCompleter.future.then((_) { + getConstructs().then((_) => updateAnalyticsStream()); + }); + } + + /// Clear all cached analytics data. + void dispose() { + _analyticsUpdateSubscription?.cancel(); + _analyticsUpdateSubscription = null; + _cache.clear(); + } + + Future onAnalyticsUpdate(AnalyticsUpdateType type) async { + if (type == AnalyticsUpdateType.server) { + await getConstructs(forceUpdate: true); + } + updateAnalyticsStream(); + } + + void updateAnalyticsStream() { + // 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?.length) { + return; + } + + // set the previous XP to the currentXP + if (analyticsStream.value != null) { + prevXP = calcXP(analyticsStream.value!); + } + + // finally, add to the stream + analyticsStream.add(allConstructUses); + } + + /// 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 errors = ConstructListModel( + uses: constructs, + type: ConstructTypeEnum.grammar, + ); + return words.points + errors.points; + } + + List get allConstructUses { + final List storedUses = getConstructsLocal() ?? []; + final List localUses = locallyCachedConstructs; + + final List allConstructs = [ + ...storedUses, + ...localUses, + ]; + + return allConstructs; + } + + /// A local cache of eventIds and construct uses for messages sent since the last update. + /// It's a map of eventIDs to a list of OneConstructUses. Not just a list of OneConstructUses + /// because, with practice activity constructs, we might need to add to the list for a given + /// eventID. Map> get messagesSinceUpdate { try { final dynamic locallySaved = _pangeaController.pStoreService.read( @@ -61,29 +150,34 @@ class GetAnalyticsController { } } + /// A flat list of all locally cached construct uses + List get locallyCachedConstructs => + messagesSinceUpdate.values.expand((e) => e).toList(); + + /// A flat list of all locally cached construct uses that are not drafts + List get locallyCachedSentConstructs => + messagesSinceUpdate.entries + .where((entry) => !entry.key.startsWith('draft')) + .expand((e) => e.value) + .toList(); + /// Get a list of all constructs used by the logged in user in their current L2 Future> getConstructs({ bool forceUpdate = false, ConstructTypeEnum? constructType, }) async { - debugPrint("getting constructs"); + // if the user isn't logged in, return an empty list + if (client.userID == null) return []; await client.roomsLoading; // don't try to get constructs until last updated time has been loaded await _pangeaController.myAnalytics.lastUpdatedCompleter.future; // if forcing a refreshing, clear the cache - if (forceUpdate) clearCache(); - - // get the last time the user updated their analytics for their current l2 - // then try to get local cache of construct uses. lastUpdate time is used to - // determine if cached data is still valid. - final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated ?? - await myAnalyticsLastUpdated(); + if (forceUpdate) _cache.clear(); final List? local = getConstructsLocal( constructType: constructType, - lastUpdated: lastUpdated, ); if (local != null) { @@ -160,7 +254,6 @@ class GetAnalyticsController { /// Get the cached construct uses for the current user, if it exists List? getConstructsLocal({ - DateTime? lastUpdated, ConstructTypeEnum? constructType, }) { final index = _cache.indexWhere( @@ -168,6 +261,7 @@ class GetAnalyticsController { ); if (index > -1) { + final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated; if (_cache[index].needsUpdate(lastUpdated)) { _cache.removeAt(index); return null; @@ -191,11 +285,6 @@ class GetAnalyticsController { ); _cache.add(entry); } - - /// Clear all cached analytics data. - void clearCache() { - _cache.clear(); - } } class AnalyticsCacheEntry { diff --git a/lib/pangea/controllers/my_analytics_controller.dart b/lib/pangea/controllers/my_analytics_controller.dart index 1b94a12ba..daefe1cd9 100644 --- a/lib/pangea/controllers/my_analytics_controller.dart +++ b/lib/pangea/controllers/my_analytics_controller.dart @@ -4,24 +4,32 @@ import 'package:fluffychat/pangea/constants/local.key.dart'; import 'package:fluffychat/pangea/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/controllers/base_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_use_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/matrix_event_wrappers/practice_activity_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.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:matrix/src/utils/cached_stream_controller.dart'; + +enum AnalyticsUpdateType { server, local } /// handles the processing of analytics for /// 1) messages sent by the user and /// 2) constructs used by the user, both in sending messages and doing practice activities class MyAnalyticsController extends BaseController { late PangeaController _pangeaController; - final StreamController analyticsUpdateStream = StreamController.broadcast(); + CachedStreamController analyticsUpdateStream = + CachedStreamController(); + StreamSubscription? _messageSendSubscription; Timer? _updateTimer; Client get _client => _pangeaController.matrixState.client; @@ -37,7 +45,7 @@ class MyAnalyticsController extends BaseController { /// the max number of messages that will be cached before /// an automatic update is triggered - final int _maxMessagesCached = 1; + final int _maxMessagesCached = 10; /// the number of minutes before an automatic update is triggered final int _minutesBeforeUpdate = 5; @@ -47,22 +55,28 @@ class MyAnalyticsController extends BaseController { MyAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; + } - // Wait for the next sync in the stream to ensure that the pangea controller - // is fully initialized. It will throw an error if it is not. - if (_pangeaController.matrixState.client.prevBatch == null) { - _pangeaController.matrixState.client.onSync.stream.first.then( - (_) => _refreshAnalyticsIfOutdated(), - ); - } else { - _refreshAnalyticsIfOutdated(); - } - + void initialize() { // Listen to a stream that provides the eventIDs // of new messages sent by the logged in user - stateStream.where((data) => data is Map).listen((data) { - onMessageSent(data as Map); - }); + _messageSendSubscription ??= stateStream + .where((data) => data is Map) + .listen((data) => onMessageSent(data as Map)); + + _refreshAnalyticsIfOutdated(); + } + + /// Reset analytics last updated time to null. + @override + void dispose() { + _updateTimer?.cancel(); + lastUpdated = null; + lastUpdatedCompleter = Completer(); + _messageSendSubscription?.cancel(); + _messageSendSubscription = null; + _refreshAnalyticsIfOutdated(); + clearMessagesSinceUpdate(); } /// If analytics haven't been updated in the last day, update them @@ -98,6 +112,7 @@ class MyAnalyticsController extends BaseController { void onMessageSent(Map data) { // cancel the last timer that was set on message event and // reset it to fire after _minutesBeforeUpdate minutes + debugPrint("ONE MESSAGE SENT"); _updateTimer?.cancel(); _updateTimer = Timer(Duration(minutes: _minutesBeforeUpdate), () { debugPrint("timer fired, updating analytics"); @@ -154,27 +169,82 @@ class MyAnalyticsController extends BaseController { _pangeaController.analytics .filterConstructs(unfilteredConstructs: constructs) - .then((filtered) => addMessageSinceUpdate(eventID, filtered)); + .then((filtered) { + if (filtered.isEmpty) return; + final level = _pangeaController.analytics.level; + addLocalMessage(eventID, filtered).then( + (_) => afterAddLocalMessages(level), + ); + }); } + /// Called when the user selects a replacement during IGC + void onReplacementSelected( + List tokens, + String roomID, + bool isBestCorrection, + ) { + final useType = isBestCorrection + ? ConstructUseTypeEnum.corIGC + : ConstructUseTypeEnum.incIGC; + setDraftConstructUses(tokens, roomID, useType); + } + + /// Called when the user ignores a match during IGC + void onIgnoreMatch( + List tokens, + String roomID, + ) { + const useType = ConstructUseTypeEnum.ignIGC; + setDraftConstructUses(tokens, roomID, useType); + } + + void setDraftConstructUses( + List tokens, + String roomID, + ConstructUseTypeEnum useType, + ) { + final metadata = ConstructUseMetaData( + roomId: roomID, + timeStamp: DateTime.now(), + ); + + final uses = tokens + .map( + (token) => OneConstructUse( + useType: useType, + lemma: token.lemma.text, + form: token.lemma.form, + constructType: ConstructTypeEnum.vocab, + metadata: metadata, + ), + ) + .toList(); + addLocalMessage('draft$roomID', uses); + } + + void clearDraftConstructUses(String roomID) { + final currentCache = _pangeaController.analytics.messagesSinceUpdate; + currentCache.remove('draft$roomID'); + setMessagesSinceUpdate(currentCache); + } + + /// Called when the user selects a continuance during IT + /// TODO implement + void onSelectContinuance() {} + /// Add a list of construct uses for a new message to the local /// cache of recently sent messages - void addMessageSinceUpdate( + Future addLocalMessage( String eventID, List constructs, - ) { + ) async { try { final currentCache = _pangeaController.analytics.messagesSinceUpdate; constructs.addAll(currentCache[eventID] ?? []); currentCache[eventID] = constructs; - setMessagesSinceUpdate(currentCache); - // if the cached has reached if max-length, update analytics - if (_pangeaController.analytics.messagesSinceUpdate.length > - _maxMessagesCached) { - debugPrint("reached max messages, updating"); - updateAnalytics(); - } + await setMessagesSinceUpdate(currentCache); } catch (e, s) { ErrorHandler.logError( e: PangeaWarningError("Failed to add message since update: $e"), @@ -184,23 +254,42 @@ class MyAnalyticsController extends BaseController { } } + /// Handles cleanup after adding a new message to the local cache. + /// If the addition brought the total number of messages in the cache + /// to the max, or if the addition triggered a level-up, update the analytics. + /// Otherwise, add a local update to the alert stream. + void afterAddLocalMessages(int prevLevel) { + if (_pangeaController.analytics.messagesSinceUpdate.length > + _maxMessagesCached) { + debugPrint("reached max messages, updating"); + updateAnalytics(); + return; + } + + final int newLevel = _pangeaController.analytics.level; + newLevel > prevLevel + ? updateAnalytics() + : analyticsUpdateStream.add(AnalyticsUpdateType.local); + } + /// Clears the local cache of recently sent constructs. Called before updating analytics void clearMessagesSinceUpdate() { _pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate); } /// Save the local cache of recently sent constructs to the local storage - void setMessagesSinceUpdate(Map> cache) { + Future setMessagesSinceUpdate( + Map> cache, + ) async { final formattedCache = {}; for (final entry in cache.entries) { final constructJsons = entry.value.map((e) => e.toJson()).toList(); formattedCache[entry.key] = constructJsons; } - _pangeaController.pStoreService.save( + await _pangeaController.pStoreService.save( PLocalKey.messagesSinceUpdate, formattedCache, ); - analyticsUpdateStream.add(null); } /// Prevent concurrent updates to analytics @@ -223,8 +312,9 @@ class MyAnalyticsController extends BaseController { try { await _updateAnalytics(); clearMessagesSinceUpdate(); + lastUpdated = DateTime.now(); - analyticsUpdateStream.add(null); + analyticsUpdateStream.add(AnalyticsUpdateType.server); } catch (err, s) { ErrorHandler.logError( e: err, @@ -241,7 +331,10 @@ class MyAnalyticsController extends BaseController { /// The analytics room is determined based on the user's current target language. Future _updateAnalytics() async { // if there's no cached construct data, there's nothing to send - if (_pangeaController.analytics.messagesSinceUpdate.isEmpty) return; + final cachedConstructs = _pangeaController.analytics.messagesSinceUpdate; + final bool onlyDraft = cachedConstructs.length == 1 && + cachedConstructs.keys.single.startsWith('draft'); + if (cachedConstructs.isEmpty || onlyDraft) return; // if missing important info, don't send analytics. Could happen if user just signed up. if (userL2 == null || _client.userID == null) return; @@ -251,17 +344,7 @@ class MyAnalyticsController extends BaseController { // and send cached analytics data to the room await analyticsRoom?.sendConstructsEvent( - _pangeaController.analytics.messagesSinceUpdate.values - .expand((e) => e) - .toList(), + _pangeaController.analytics.locallyCachedSentConstructs, ); } - - /// Reset analytics last updated time to null. - void clearCache() { - _updateTimer?.cancel(); - lastUpdated = null; - lastUpdatedCompleter = Completer(); - _refreshAnalyticsIfOutdated(); - } } diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart index a62ec0428..243a49174 100644 --- a/lib/pangea/controllers/pangea_controller.dart +++ b/lib/pangea/controllers/pangea_controller.dart @@ -141,6 +141,19 @@ class PangeaController { /// check user information if not found then redirect to Date of birth page _handleLoginStateChange(LoginState state) { + switch (state) { + case LoginState.loggedOut: + case LoginState.softLoggedOut: + // Reset cached analytics data + MatrixState.pangeaController.myAnalytics.dispose(); + MatrixState.pangeaController.analytics.dispose(); + break; + case LoginState.loggedIn: + // Initialize analytics data + MatrixState.pangeaController.myAnalytics.initialize(); + MatrixState.pangeaController.analytics.initialize(); + break; + } if (state != LoginState.loggedIn) { _logOutfromPangea(); } diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index a22aa82e1..8d8aeaff3 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -82,20 +82,27 @@ class ConstructListModel { /// The total number of points for all uses of this construct type int get points { - double totalPoints = 0; + // double totalPoints = 0; + return typedConstructs.fold( + 0, + (total, typedConstruct) => + total + + typedConstruct.useType.pointValue * typedConstruct.uses.length, + ); + // Commenting this out for now // Minimize the amount of points given for repeated uses of the same lemma. // i.e., if a lemma is used 4 times without assistance, the point value for // a use without assistance is 3. So the points would be // 3/1 + 3/2 + 3/3 + 3/4 = 3 + 1.5 + 1 + 0.75 = 5.25 (instead of 12) - for (final typedConstruct in typedConstructs) { - final pointValue = typedConstruct.useType.pointValue; - double calc = 0.0; - for (int k = 1; k <= typedConstruct.uses.length; k++) { - calc += pointValue / k; - } - totalPoints += calc; - } - return totalPoints.round(); + // for (final typedConstruct in typedConstructs) { + // final pointValue = typedConstruct.useType.pointValue; + // double calc = 0.0; + // for (int k = 1; k <= typedConstruct.uses.length; k++) { + // calc += pointValue / k; + // } + // totalPoints += calc; + // } + // return totalPoints.round(); } } diff --git a/lib/pangea/models/igc_text_data_model.dart b/lib/pangea/models/igc_text_data_model.dart index 5f32f92d1..014b39524 100644 --- a/lib/pangea/models/igc_text_data_model.dart +++ b/lib/pangea/models/igc_text_data_model.dart @@ -227,37 +227,6 @@ class IGCTextData { decorationThickness: 5, ); - List getMatchTokens() { - final List matchTokens = []; - int? endTokenIndex; - PangeaMatch? topMatch; - for (final (i, token) in tokens.indexed) { - if (endTokenIndex != null) { - if (i <= endTokenIndex) { - matchTokens.add( - MatchToken( - token: token, - match: topMatch, - ), - ); - continue; - } - endTokenIndex = null; - } - topMatch = getTopMatchForToken(token); - if (topMatch != null) { - endTokenIndex = tokens.indexWhere((e) => e.end >= topMatch!.end, i); - } - matchTokens.add( - MatchToken( - token: token, - match: topMatch, - ), - ); - } - return matchTokens; - } - TextSpan getSpanItem({ required int start, required int end, @@ -347,11 +316,19 @@ class IGCTextData { return items; } -} -class MatchToken { - final PangeaToken token; - final PangeaMatch? match; + List matchTokens(int matchIndex) { + if (matchIndex >= matches.length) { + return []; + } - MatchToken({required this.token, this.match}); + final PangeaMatch match = matches[matchIndex]; + final List tokensForMatch = []; + for (final token in tokens) { + if (match.isOffsetInMatchSpan(token.text.offset)) { + tokensForMatch.add(token); + } + } + return tokensForMatch; + } } diff --git a/lib/pangea/models/span_data.dart b/lib/pangea/models/span_data.dart index bf8ab8eca..8b7aaddac 100644 --- a/lib/pangea/models/span_data.dart +++ b/lib/pangea/models/span_data.dart @@ -5,6 +5,8 @@ // Call to server for additional/followup info import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/constants/model_keys.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:flutter/material.dart'; import '../enum/span_choice_type.dart'; @@ -99,14 +101,31 @@ class Context { } class SpanChoice { + String value; + SpanChoiceType type; + bool selected; + String? feedback; + DateTime? timestamp; + List tokens; + SpanChoice({ required this.value, required this.type, this.feedback, this.selected = false, this.timestamp, + this.tokens = const [], }); + factory SpanChoice.fromJson(Map json) { + final List tokensInternal = (json[ModelKey.tokens] != null) + ? (json[ModelKey.tokens] as Iterable) + .map( + (e) => PangeaToken.fromJson(e as Map), + ) + .toList() + .cast() + : []; return SpanChoice( value: json['value'] as String, type: json['type'] != null @@ -119,21 +138,17 @@ class SpanChoice { selected: json['selected'] ?? false, timestamp: json['timestamp'] != null ? DateTime.parse(json['timestamp']) : null, + tokens: tokensInternal, ); } - String value; - SpanChoiceType type; - bool selected; - String? feedback; - DateTime? timestamp; - Map toJson() => { 'value': value, 'type': type.name, 'selected': selected, 'feedback': feedback, 'timestamp': timestamp?.toIso8601String(), + 'tokens': tokens.map((e) => e.toJson()).toList(), }; String feedbackToDisplay(BuildContext context) { diff --git a/lib/pangea/utils/logout.dart b/lib/pangea/utils/logout.dart index 3d3e780ba..6c57754ef 100644 --- a/lib/pangea/utils/logout.dart +++ b/lib/pangea/utils/logout.dart @@ -22,10 +22,6 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async { // before wiping out locally cached construct data, save it to the server await MatrixState.pangeaController.myAnalytics.updateAnalytics(); - // Reset cached analytics data - MatrixState.pangeaController.myAnalytics.clearCache(); - MatrixState.pangeaController.analytics.clearCache(); - await showFutureLoadingDialog( context: context, future: () => matrix.client.logout(), diff --git a/lib/pangea/widgets/animations/gain_points.dart b/lib/pangea/widgets/animations/gain_points.dart new file mode 100644 index 000000000..dfbee2931 --- /dev/null +++ b/lib/pangea/widgets/animations/gain_points.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; + +class PointsGainedAnimation extends StatefulWidget { + final Color? color; + const PointsGainedAnimation({super.key, this.color}); + + @override + PointsGainedAnimationState createState() => PointsGainedAnimationState(); +} + +class PointsGainedAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _fadeAnimation; + + StreamSubscription? _pointsSubscription; + int? get _prevXP => MatrixState.pangeaController.analytics.prevXP; + int? get _currentXP => MatrixState.pangeaController.analytics.currentXP; + int? _addedPoints; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + + _offsetAnimation = Tween( + begin: const Offset(0.0, 0.0), + end: const Offset(0.0, -1.0), + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + + _fadeAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ), + ); + + _pointsSubscription = MatrixState + .pangeaController.analytics.analyticsStream.stream + .listen(_showPointsGained); + } + + @override + void dispose() { + _controller.dispose(); + _pointsSubscription?.cancel(); + super.dispose(); + } + + void _showPointsGained(List constructs) { + setState(() => _addedPoints = (_currentXP ?? 0) - (_prevXP ?? 0)); + if (_prevXP != _currentXP && !_controller.isAnimating) { + _controller.reset(); + _controller.forward(); + } + } + + bool get animate => + _currentXP != null && + _prevXP != null && + _addedPoints != null && + _prevXP! != _currentXP!; + + @override + Widget build(BuildContext context) { + if (!animate) return const SizedBox(); + + return SlideTransition( + position: _offsetAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: Text( + '+$_addedPoints', + style: BotStyle.text( + context, + big: true, + setColor: widget.color == null, + existingStyle: TextStyle( + color: widget.color, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/animations/progress_bar/animated_level_dart.dart b/lib/pangea/widgets/animations/progress_bar/animated_level_dart.dart new file mode 100644 index 000000000..adbc32e33 --- /dev/null +++ b/lib/pangea/widgets/animations/progress_bar/animated_level_dart.dart @@ -0,0 +1,87 @@ +import 'package:fluffychat/config/themes.dart'; +import 'package:flutter/material.dart'; + +class AnimatedLevelBar extends StatefulWidget { + final double height; + final double beginWidth; + final double endWidth; + final BoxDecoration? decoration; + + const AnimatedLevelBar({ + super.key, + required this.height, + required this.beginWidth, + required this.endWidth, + this.decoration, + }); + + @override + AnimatedLevelBarState createState() => AnimatedLevelBarState(); +} + +class AnimatedLevelBarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + /// Whether the animation has run for the first time during initState. Don't + /// want the animation to run when the widget mounts, only when points are gained. + bool _init = true; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: FluffyThemes.animationDuration, + ); + + _controller.forward().then((_) => _init = false); + } + + @override + void didUpdateWidget(covariant AnimatedLevelBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.endWidth != widget.endWidth) { + _controller.reset(); + _controller.forward(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Animation get _animation { + // If this is the first run of the animation, don't animate. This is just the widget mounting, + // not a points gain. This could instead be 'if going from 0 to a non-zero value', but that + // would remove the animation for first points gained. It would remove the need for a flag though. + if (_init) { + return Tween( + begin: widget.endWidth, + end: widget.endWidth, + ).animate(_controller); + } + + // animate the width of the bar + return Tween( + begin: widget.beginWidth, + end: widget.endWidth, + ).animate(_controller); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + height: widget.height, + width: _animation.value, + decoration: widget.decoration, + ); + }, + ); + } +} diff --git a/lib/pangea/widgets/animations/progress_bar/level_bar.dart b/lib/pangea/widgets/animations/progress_bar/level_bar.dart new file mode 100644 index 000000000..fb57a3bd5 --- /dev/null +++ b/lib/pangea/widgets/animations/progress_bar/level_bar.dart @@ -0,0 +1,59 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; +import 'package:fluffychat/pangea/widgets/animations/progress_bar/animated_level_dart.dart'; +import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart'; +import 'package:flutter/material.dart'; + +class LevelBar extends StatefulWidget { + final LevelBarDetails details; + final ProgressBarDetails progressBarDetails; + + const LevelBar({ + super.key, + required this.details, + required this.progressBarDetails, + }); + + @override + LevelBarState createState() => LevelBarState(); +} + +class LevelBarState extends State { + double prevWidth = 0; + + double get width { + const perLevel = AnalyticsConstants.xpPerLevel; + final percent = (widget.details.currentPoints % perLevel) / perLevel; + return widget.progressBarDetails.totalWidth * percent; + } + + @override + void didUpdateWidget(covariant LevelBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.details.currentPoints != widget.details.currentPoints) { + setState(() => prevWidth = width); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedLevelBar( + height: widget.progressBarDetails.height, + beginWidth: prevWidth, + endWidth: width, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(AppConfig.borderRadius), + ), + color: widget.details.fillColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 5, + offset: const Offset(5, 0), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/widgets/animations/progress_bar/progress_bar.dart b/lib/pangea/widgets/animations/progress_bar/progress_bar.dart new file mode 100644 index 000000000..ea0263a3c --- /dev/null +++ b/lib/pangea/widgets/animations/progress_bar/progress_bar.dart @@ -0,0 +1,36 @@ +import 'package:fluffychat/pangea/widgets/animations/progress_bar/level_bar.dart'; +import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_background.dart'; +import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart'; +import 'package:flutter/material.dart'; + +// Provide an order list of level indicators, each with it's color +// and stream. Also provide an overall width and pointsPerLevel. + +class ProgressBar extends StatelessWidget { + final List levelBars; + final ProgressBarDetails progressBarDetails; + + const ProgressBar({ + super.key, + required this.levelBars, + required this.progressBarDetails, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.centerLeft, + children: [ + ProgressBarBackground(details: progressBarDetails), + for (final levelBar in levelBars) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: LevelBar( + details: levelBar, + progressBarDetails: progressBarDetails, + ), + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/animations/progress_bar/progress_bar_background.dart b/lib/pangea/widgets/animations/progress_bar/progress_bar_background.dart new file mode 100644 index 000000000..1ebfe145d --- /dev/null +++ b/lib/pangea/widgets/animations/progress_bar/progress_bar_background.dart @@ -0,0 +1,30 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/widgets/animations/progress_bar/progress_bar_details.dart'; +import 'package:flutter/material.dart'; + +class ProgressBarBackground extends StatelessWidget { + final ProgressBarDetails details; + + const ProgressBarBackground({ + super.key, + required this.details, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: details.height + 4, + width: details.totalWidth + 4, + decoration: BoxDecoration( + border: Border.all( + color: details.borderColor.withOpacity(0.5), + width: 2, + ), + borderRadius: const BorderRadius.all( + Radius.circular(AppConfig.borderRadius), + ), + color: details.borderColor.withOpacity(0.2), + ), + ); + } +} diff --git a/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart b/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart new file mode 100644 index 000000000..debe93816 --- /dev/null +++ b/lib/pangea/widgets/animations/progress_bar/progress_bar_details.dart @@ -0,0 +1,23 @@ +import 'dart:ui'; + +class LevelBarDetails { + final Color fillColor; + final int currentPoints; + + const LevelBarDetails({ + required this.fillColor, + required this.currentPoints, + }); +} + +class ProgressBarDetails { + final double totalWidth; + final Color borderColor; + final double height; + + const ProgressBarDetails({ + required this.totalWidth, + required this.borderColor, + this.height = 16, + }); +} 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 07fd02604..bf8bf2f96 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,12 +1,15 @@ import 'dart:async'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/constants/analytics_constants.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/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/widgets/animations/gain_points.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/progress_indicator.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -34,7 +37,7 @@ class LearningProgressIndicatorsState /// A stream subscription to listen for updates to /// the analytics data, either locally or from events - StreamSubscription? _onAnalyticsUpdate; + StreamSubscription>? _analyticsUpdateSubscription; /// Vocabulary constructs model ConstructListModel? words; @@ -44,66 +47,39 @@ class LearningProgressIndicatorsState bool loading = true; - /// The previous number of XP points, used to determine when to animate the level bar - int? previousXP; + int get serverXP => _pangeaController.analytics.serverXP; + int get totalXP => _pangeaController.analytics.currentXP; + int get level => _pangeaController.analytics.level; @override void initState() { super.initState(); - updateAnalyticsData().then((_) { - setState(() => loading = false); - }); - // listen for changes to analytics data and update the UI - _onAnalyticsUpdate = _pangeaController - .myAnalytics.analyticsUpdateStream.stream - .listen((_) => updateAnalyticsData()); + updateAnalyticsData( + _pangeaController.analytics.analyticsStream.value ?? [], + ); + _pangeaController.analytics.analyticsStream.stream + .listen(updateAnalyticsData); } @override void dispose() { - _onAnalyticsUpdate?.cancel(); + _analyticsUpdateSubscription?.cancel(); super.dispose(); } /// Update the analytics data shown in the UI. This comes from a /// combination of stored events and locally cached data. - Future updateAnalyticsData() async { - previousXP = xpPoints; - - final List storedUses = - await _pangeaController.analytics.getConstructs(); - final List localUses = []; - for (final uses in _pangeaController.analytics.messagesSinceUpdate.values) { - localUses.addAll(uses); - } - - if (storedUses.isEmpty) { - words = ConstructListModel( - type: ConstructTypeEnum.vocab, - uses: localUses, - ); - errors = ConstructListModel( - type: ConstructTypeEnum.grammar, - uses: localUses, - ); - setState(() {}); - return; - } - - final List allConstructs = [ - ...storedUses, - ...localUses, - ]; - + Future updateAnalyticsData(List constructs) async { words = ConstructListModel( type: ConstructTypeEnum.vocab, - uses: allConstructs, + uses: constructs, ); errors = ConstructListModel( type: ConstructTypeEnum.grammar, - uses: allConstructs, + uses: constructs, ); + if (loading) loading = false; if (mounted) setState(() {}); } @@ -119,21 +95,10 @@ class LearningProgressIndicatorsState } } - /// Get the total number of xp points, based on the point values of use types. - /// Null if niether words nor error constructs are available. - int? get xpPoints { - if (words == null && errors == null) return null; - if (words == null) return errors!.points; - if (errors == null) return words!.points; - return words!.points + errors!.points; - } - - /// Get the current level based on the number of xp points - int get level => (xpPoints ?? 0) ~/ 500; - double get levelBarWidth => FluffyThemes.columnWidth - (32 * 2) - 25; double get pointsBarWidth { - final percent = ((xpPoints ?? 0) % 500) / 500; + final percent = (totalXP % AnalyticsConstants.xpPerLevel) / + AnalyticsConstants.xpPerLevel; return levelBarWidth * percent; } @@ -149,61 +114,42 @@ class LearningProgressIndicatorsState return colors[level % colors.length]; } - /// Whether to animate the level bar increase. Prevents this bar from seeming to - /// reload each time the user navigates to a different space or back to the chat list. - /// PreviousXP would be null if this widget just mounted. Also handles case of rebuilds - /// without any change in XP points. - bool get animate => previousXP != null && previousXP != xpPoints; - @override Widget build(BuildContext context) { if (Matrix.of(context).client.userID == null) { return const SizedBox(); } - final levelBar = Container( - height: 20, - width: levelBarWidth, - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.5), - width: 2, + final progressBar = ProgressBar( + levelBars: [ + LevelBarDetails( + fillColor: const Color.fromARGB(255, 0, 190, 83), + currentPoints: totalXP, ), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(AppConfig.borderRadius), - bottomRight: Radius.circular(AppConfig.borderRadius), + LevelBarDetails( + fillColor: Theme.of(context).colorScheme.primary, + currentPoints: serverXP, ), - color: Theme.of(context).colorScheme.primary.withOpacity(0.2), + ], + progressBarDetails: ProgressBarDetails( + totalWidth: levelBarWidth, + borderColor: Theme.of(context).colorScheme.primary.withOpacity(0.5), ), ); - final xpBarDecoration = BoxDecoration( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(AppConfig.borderRadius), - bottomRight: Radius.circular(AppConfig.borderRadius), - ), - color: Theme.of(context).colorScheme.primary, - ); - - final xpBar = animate - ? AnimatedContainer( - duration: FluffyThemes.animationDuration, - height: 16, - width: pointsBarWidth, - decoration: xpBarDecoration, - ) - : Container( - height: 16, - width: pointsBarWidth, - decoration: xpBarDecoration, - ); - 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( @@ -213,61 +159,71 @@ class LearningProgressIndicatorsState ), ); - return Column( - mainAxisSize: MainAxisSize.min, + return Stack( + alignment: Alignment.topCenter, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(46, 0, 32, 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - FutureBuilder( - future: - _pangeaController.matrixState.client.getProfileFromUserId( - _pangeaController.matrixState.client.userID!, - ), - builder: (context, snapshot) { - final mxid = Matrix.of(context).client.userID ?? - L10n.of(context)!.user; - return Avatar( - name: snapshot.data?.displayName ?? mxid.localpart ?? mxid, - mxContent: snapshot.data?.avatarUrl, - size: 40, - ); - }, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: ProgressIndicatorEnum.values - .where( - (indicator) => indicator != ProgressIndicatorEnum.level, - ) - .map( - (indicator) => ProgressIndicatorBadge( - points: getProgressPoints(indicator), - onTap: () {}, - progressIndicator: indicator, - loading: loading, - ), - ) - .toList(), - ), - ], - ), + const Positioned( + child: PointsGainedAnimation(), ), - Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Stack( - alignment: Alignment.center, - children: [ - Positioned(left: 16, right: 0, child: levelBar), - Positioned(left: 16, child: xpBar), - Positioned(left: 0, child: levelBadge), - ], - ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(46, 0, 32, 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FutureBuilder( + future: _pangeaController.matrixState.client + .getProfileFromUserId( + _pangeaController.matrixState.client.userID!, + ), + builder: (context, snapshot) { + final mxid = Matrix.of(context).client.userID ?? + L10n.of(context)!.user; + return Avatar( + name: snapshot.data?.displayName ?? + mxid.localpart ?? + mxid, + mxContent: snapshot.data?.avatarUrl, + size: 40, + ); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: ProgressIndicatorEnum.values + .where( + (indicator) => + indicator != ProgressIndicatorEnum.level, + ) + .map( + (indicator) => ProgressIndicatorBadge( + points: getProgressPoints(indicator), + onTap: () {}, + progressIndicator: indicator, + loading: loading, + ), + ) + .toList(), + ), + ], + ), + ), + Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned(left: 16, right: 0, child: progressBar), + Positioned(left: 0, child: levelBadge), + ], + ), + ), + const SizedBox(height: 16), + ], ), - const SizedBox(height: 16), ], ); } diff --git a/lib/pangea/widgets/igc/span_card.dart b/lib/pangea/widgets/igc/span_card.dart index 26046f8d0..a431f5666 100644 --- a/lib/pangea/widgets/igc/span_card.dart +++ b/lib/pangea/widgets/igc/span_card.dart @@ -20,8 +20,6 @@ import '../common/bot_face_svg.dart'; import 'card_header.dart'; import 'why_button.dart'; -const wordMatchResultsCount = 5; - //switch for definition vs correction vs practice //always show a title @@ -32,12 +30,12 @@ const wordMatchResultsCount = 5; class SpanCard extends StatefulWidget { final PangeaController pangeaController = MatrixState.pangeaController; final SpanCardModel scm; - final String? roomId; + final String roomId; SpanCard({ super.key, required this.scm, - this.roomId, + required this.roomId, }); @override @@ -62,12 +60,16 @@ class SpanCardState extends State { //get selected choice SpanChoice? get selectedChoice { - if (selectedChoiceIndex == null || - widget.scm.pangeaMatch?.match.choices == null || - widget.scm.pangeaMatch!.match.choices!.length <= selectedChoiceIndex!) { + if (selectedChoiceIndex == null) return null; + return choiceByIndex(selectedChoiceIndex!); + } + + SpanChoice? choiceByIndex(int index) { + if (widget.scm.pangeaMatch?.match.choices == null || + widget.scm.pangeaMatch!.match.choices!.length <= index) { return null; } - return widget.scm.pangeaMatch?.match.choices?[selectedChoiceIndex!]; + return widget.scm.pangeaMatch?.match.choices?[index]; } void fetchSelected() { @@ -76,8 +78,9 @@ class SpanCardState extends State { } if (selectedChoiceIndex == null) { DateTime? mostRecent; - for (int i = 0; i < widget.scm.pangeaMatch!.match.choices!.length; i++) { - final choice = widget.scm.pangeaMatch?.match.choices![i]; + final numChoices = widget.scm.pangeaMatch!.match.choices!.length; + for (int i = 0; i < numChoices; i++) { + final choice = choiceByIndex(i); if (choice!.timestamp != null && (mostRecent == null || choice.timestamp!.isAfter(mostRecent))) { mostRecent = choice.timestamp; @@ -114,6 +117,58 @@ class SpanCardState extends State { } } + Future onChoiceSelect(int index) async { + selectedChoiceIndex = index; + if (selectedChoice != null) { + selectedChoice!.timestamp = DateTime.now(); + selectedChoice!.selected = true; + setState( + () => (selectedChoice!.isBestCorrection + ? BotExpression.gold + : BotExpression.surprised), + ); + } + } + + void onReplaceSelected() { + if (selectedChoice != null) { + final tokens = widget.scm.choreographer.igc.igcTextData + ?.matchTokens(widget.scm.matchIndex) ?? + []; + MatrixState.pangeaController.myAnalytics.onReplacementSelected( + tokens, + widget.roomId, + selectedChoice!.isBestCorrection, + ); + } + + widget.scm + .onReplacementSelect( + matchIndex: widget.scm.matchIndex, + choiceIndex: selectedChoiceIndex!, + ) + .then((value) { + setState(() {}); + }); + } + + void onIgnoreMatch() { + MatrixState.pAnyState.closeOverlay(); + Future.delayed( + Duration.zero, + () { + widget.scm.onIgnore(); + final tokens = widget.scm.choreographer.igc.igcTextData + ?.matchTokens(widget.scm.matchIndex) ?? + []; + MatrixState.pangeaController.myAnalytics.onIgnoreMatch( + tokens, + widget.roomId, + ); + }, + ); + } + @override Widget build(BuildContext context) { return WordMatchContent(controller: this); @@ -129,61 +184,6 @@ class WordMatchContent extends StatelessWidget { super.key, }); - Future onChoiceSelect(int index) async { - controller.selectedChoiceIndex = index; - controller - .widget - .scm - .choreographer - .igc - .igcTextData - ?.matches[controller.widget.scm.matchIndex] - .match - .choices?[index] - .timestamp = DateTime.now(); - controller - .widget - .scm - .choreographer - .igc - .igcTextData - ?.matches[controller.widget.scm.matchIndex] - .match - .choices?[index] - .selected = true; - - controller.setState( - () => (controller.currentExpression = controller - .widget - .scm - .choreographer - .igc - .igcTextData! - .matches[controller.widget.scm.matchIndex] - .match - .choices![index] - .isBestCorrection - ? BotExpression.gold - : BotExpression.surprised), - ); - // if (controller.widget.scm.pangeaMatch.match.choices![index].type == - // SpanChoiceType.distractor) { - // await controller.getSpanDetails(); - // } - // controller.setState(() {}); - } - - void onReplaceSelected() { - controller.widget.scm - .onReplacementSelect( - matchIndex: controller.widget.scm.matchIndex, - choiceIndex: controller.selectedChoiceIndex!, - ) - .then((value) { - controller.setState(() {}); - }); - } - @override Widget build(BuildContext context) { if (controller.widget.scm.pangeaMatch == null) { @@ -248,7 +248,7 @@ class WordMatchContent extends StatelessWidget { ), ) .toList(), - onPressed: onChoiceSelect, + onPressed: controller.onChoiceSelect, uniqueKeyForLayerLink: (int index) => "wordMatch$index", selectedChoiceIndex: controller.selectedChoiceIndex, ), @@ -272,13 +272,7 @@ class WordMatchContent extends StatelessWidget { AppConfig.primaryColor.withOpacity(0.1), ), ), - onPressed: () { - MatrixState.pAnyState.closeOverlay(); - Future.delayed( - Duration.zero, - () => controller.widget.scm.onIgnore(), - ); - }, + onPressed: controller.onIgnoreMatch, child: Center( child: Text(L10n.of(context)!.ignoreInThisText), ), @@ -292,7 +286,7 @@ class WordMatchContent extends StatelessWidget { opacity: controller.selectedChoiceIndex != null ? 1.0 : 0.5, child: TextButton( onPressed: controller.selectedChoiceIndex != null - ? onReplaceSelected + ? controller.onReplaceSelected : null, style: ButtonStyle( backgroundColor: WidgetStateProperty.all( @@ -352,7 +346,6 @@ class WordMatchContent extends StatelessWidget { } on Exception catch (e) { debugger(when: kDebugMode); ErrorHandler.logError(e: e, s: StackTrace.current); - print(e); rethrow; } } diff --git a/lib/pangea/widgets/igc/span_data.dart b/lib/pangea/widgets/igc/span_data.dart deleted file mode 100644 index a92f32f74..000000000 --- a/lib/pangea/widgets/igc/span_data.dart +++ /dev/null @@ -1,184 +0,0 @@ -//Possible actions/effects from cards -// Nothing -// useType of viewed definitions -// SpanChoice of text in message from options -// Call to server for additional/followup info - -import 'package:collection/collection.dart'; - -import '../../enum/span_data_type.dart'; - -class SpanData { - String? message; - String? shortMessage; - List? choices; - List? replacements; - int offset; - int length; - String fullText; - Context? context; - SpanDataTypeEnum type; - Rule? rule; - - SpanData({ - this.message, - this.shortMessage, - this.choices, - this.replacements, - required this.offset, - required this.length, - required this.fullText, - this.context, - required this.type, - this.rule, - }); - - factory SpanData.fromJson(Map json) { - return SpanData( - message: json['message'], - shortMessage: json['shortMessage'], - choices: json['choices'] != null - ? List.from( - json['choices'].map((x) => SpanChoice.fromJson(x)), - ) - : null, - replacements: json['replacements'] != null - ? List.from( - json['replacements'].map((x) => Replacement.fromJson(x)), - ) - : null, - offset: json['offset'], - length: json['length'], - fullText: json['full_text'], - context: - json['context'] != null ? Context.fromJson(json['context']) : null, - type: SpanDataTypeEnum.values.firstWhereOrNull( - (e) => e.toString() == 'SpanDataTypeEnum.${json['type']}', - ) ?? - SpanDataTypeEnum.correction, - rule: json['rule'] != null ? Rule.fromJson(json['rule']) : null, - ); - } - - Map toJson() { - final Map data = {}; - data['message'] = message; - data['shortMessage'] = shortMessage; - if (choices != null) { - data['choices'] = choices!.map((x) => x.toJson()).toList(); - } - if (replacements != null) { - data['replacements'] = replacements!.map((x) => x.toJson()).toList(); - } - data['offset'] = offset; - data['length'] = length; - data['full_text'] = fullText; - if (context != null) { - data['context'] = context!.toJson(); - } - data['type'] = type.toString().split('.').last; - if (rule != null) { - data['rule'] = rule!.toJson(); - } - return data; - } -} - -class Context { - String sentence; - int offset; - int length; - - Context({required this.sentence, required this.offset, required this.length}); - - factory Context.fromJson(Map json) { - return Context( - sentence: json['sentence'], - offset: json['offset'], - length: json['length'], - ); - } - - Map toJson() { - final Map data = {}; - data['sentence'] = sentence; - data['offset'] = offset; - data['length'] = length; - return data; - } -} - -class SpanChoice { - String value; - bool selected; - - SpanChoice({required this.value, required this.selected}); - - factory SpanChoice.fromJson(Map json) { - return SpanChoice( - value: json['value'], - selected: json['selected'], - ); - } - - Map toJson() { - final Map data = {}; - data['value'] = value; - data['selected'] = selected; - return data; - } -} - -class Replacement { - String value; - - Replacement({required this.value}); - - factory Replacement.fromJson(Map json) { - return Replacement( - value: json['value'], - ); - } - - Map toJson() { - final Map data = {}; - data['value'] = value; - return data; - } -} - -class Rule { - String id; - - Rule({required this.id}); - - factory Rule.fromJson(Map json) { - return Rule( - id: json['id'], - ); - } - - Map toJson() { - final Map data = {}; - data['id'] = id; - return data; - } -} - -class SpanDataType { - String type; - - SpanDataType({required this.type}); - - factory SpanDataType.fromJson(Map json) { - return SpanDataType( - type: json['type'], - ); - } - - Map toJson() { - final Map data = {}; - data['type'] = type; - return data; - } -} diff --git a/lib/pangea/widgets/user_settings/p_language_dialog.dart b/lib/pangea/widgets/user_settings/p_language_dialog.dart index 5b08d3cad..43096ea54 100644 --- a/lib/pangea/widgets/user_settings/p_language_dialog.dart +++ b/lib/pangea/widgets/user_settings/p_language_dialog.dart @@ -91,20 +91,28 @@ Future pLanguageDialog( context: context, future: () async { try { - pangeaController.userController.updateProfile( - (profile) { - profile.userSettings.sourceLanguage = - selectedSourceLanguage.langCode; - profile.userSettings.targetLanguage = - selectedTargetLanguage.langCode; - return profile; - }, - waitForDataInSync: true, - ).then((_) { + pangeaController.myAnalytics + .updateAnalytics() + .then((_) { + pangeaController.userController.updateProfile( + (profile) { + profile.userSettings.sourceLanguage = + selectedSourceLanguage.langCode; + profile.userSettings.targetLanguage = + selectedTargetLanguage.langCode; + return profile; + }, + waitForDataInSync: true, + ); + }).then((_) { // if the profile update is successful, reset cached analytics // data, since analytics data corresponds to the user's L2 - pangeaController.myAnalytics.clearCache(); - pangeaController.analytics.clearCache(); + pangeaController.myAnalytics.dispose(); + pangeaController.analytics.dispose(); + + pangeaController.myAnalytics.initialize(); + pangeaController.analytics.initialize(); + Navigator.pop(context); }); } catch (err, s) {