From 16fbc4a52e248eb9ba181d4e8600d048b388ad99 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:58:01 -0500 Subject: [PATCH 1/6] feat: expose construct level up stream --- lib/pages/chat/chat.dart | 35 +++++++++++-------- .../analytics_data_service.dart | 20 +++++++++-- .../analytics_update_dispatcher.dart | 18 ++++++++++ .../analytics_update_events.dart | 10 ++++++ 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 18a21f926..6c6415f87 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -1,23 +1,9 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:universal_html/html.dart' as html; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; @@ -88,6 +74,19 @@ import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart' import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/share_scaffold_dialog.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_html/html.dart' as html; + import '../../utils/localized_exception_extension.dart'; import 'send_file_dialog.dart'; import 'send_location_dialog.dart'; @@ -192,6 +191,7 @@ class ChatController extends State StreamSubscription? _levelSubscription; StreamSubscription? _constructsSubscription; StreamSubscription? _tokensSubscription; + StreamSubscription? _constructLevelSubscription; StreamSubscription? _botAudioSubscription; final timelineUpdateNotifier = _TimelineUpdateNotifier(); @@ -529,6 +529,12 @@ class ChatController extends State choreographer.timesDismissedIT.addListener(_onCloseIT); final updater = Matrix.of(context).analyticsDataService.updateDispatcher; + _constructLevelSubscription = + updater.constructLevelUpdateStream.stream.listen((entry) { + debugPrint( + "Construct level update received: ${entry.key.string} -> ${entry.value}", + ); + }); _levelSubscription = updater.levelUpdateStream.stream.listen(_onLevelUp); _constructsSubscription = @@ -800,6 +806,7 @@ class ChatController extends State _levelSubscription?.cancel(); _botAudioSubscription?.cancel(); _constructsSubscription?.cancel(); + _constructLevelSubscription?.cancel(); _tokensSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); choreographer.timesDismissedIT.removeListener(_onCloseIT); diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 31032b887..2efe477cd 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pangea/analytics_data/analytics_database.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_database_builder.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_sync_controller.dart'; @@ -19,9 +17,11 @@ import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:matrix/matrix.dart'; class _AnalyticsClient { final Client client; @@ -442,6 +442,22 @@ class AnalyticsDataService { events.add(MorphUnlockedEvent(newUnlockedMorphs)); } + for (final entry in newConstructs.entries) { + final prevConstruct = prevConstructs[entry.key]; + if (prevConstruct == null) continue; + + final prevLevel = prevConstruct.lemmaCategory; + final newLevel = entry.value.lemmaCategory; + if (newLevel.xpNeeded > prevLevel.xpNeeded) { + events.add( + ConstructLevelUpEvent( + entry.key, + newLevel, + ), + ); + } + } + if (update.blockedConstruct != null) { events.add(ConstructBlockedEvent(update.blockedConstruct!)); } diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index 4182fec99..d8074e9c3 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; class LevelUpdate { @@ -46,6 +47,10 @@ class AnalyticsUpdateDispatcher { final StreamController> newConstructsStream = StreamController>.broadcast(); + final StreamController> + constructLevelUpdateStream = StreamController< + MapEntry>.broadcast(); + final StreamController> _lemmaInfoUpdateStream = StreamController< MapEntry>.broadcast(); @@ -58,6 +63,7 @@ class AnalyticsUpdateDispatcher { unlockedConstructsStream.close(); levelUpdateStream.close(); _lemmaInfoUpdateStream.close(); + constructLevelUpdateStream.close(); } Stream lemmaUpdateStream( @@ -101,6 +107,9 @@ class AnalyticsUpdateDispatcher { case final ConstructBlockedEvent e: _onBlockedConstruct(e.blockedConstruct); break; + case final ConstructLevelUpEvent e: + _onConstructLevelUp(e.constructId, e.level); + break; case final NewConstructsEvent e: _onNewConstruct(e.newConstructs); break; @@ -144,6 +153,15 @@ class AnalyticsUpdateDispatcher { constructUpdateStream.add(update); } + void _onConstructLevelUp( + ConstructIdentifier constructId, + ConstructLevelEnum level, + ) { + constructLevelUpdateStream.add( + MapEntry(constructId, level), + ); + } + void _onNewConstruct(Set constructIds) { if (constructIds.isEmpty) return; newConstructsStream.add(constructIds); diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index 1d79afb02..e00d1b96b 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; sealed class AnalyticsUpdateEvent {} @@ -13,6 +14,15 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent { MorphUnlockedEvent(this.unlocked); } +class ConstructLevelUpEvent extends AnalyticsUpdateEvent { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + ConstructLevelUpEvent( + this.constructId, + this.level, + ); +} + class XPGainedEvent extends AnalyticsUpdateEvent { final int points; final String? targetID; From 096ba06367e3d1f50a14f4b5a4e55511681fe016 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:47:15 -0500 Subject: [PATCH 2/6] feat: rise and fade animation for construct levels --- lib/pages/chat/chat.dart | 24 ++++- .../analytics_data_service.dart | 1 + .../analytics_update_dispatcher.dart | 26 ++++- .../analytics_update_events.dart | 2 + .../analytics_misc/growth_animation.dart | 98 +++++++++++++++++++ lib/pangea/common/utils/overlay.dart | 67 ++++++++++++- 6 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 lib/pangea/analytics_misc/growth_animation.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 6c6415f87..5357d5cae 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -39,6 +39,7 @@ import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; @@ -530,10 +531,14 @@ class ChatController extends State final updater = Matrix.of(context).analyticsDataService.updateDispatcher; _constructLevelSubscription = - updater.constructLevelUpdateStream.stream.listen((entry) { - debugPrint( - "Construct level update received: ${entry.key.string} -> ${entry.value}", - ); + updater.constructLevelUpdateStream.stream.listen((update) { + if (update.targetID != null) { + OverlayUtil.showGrowthOverlay( + context, + update.targetID!, + level: update.level, + ); + } }); _levelSubscription = updater.levelUpdateStream.stream.listen(_onLevelUp); @@ -2340,6 +2345,17 @@ class ChatController extends State ConstructTypeEnum.vocab, ); + // Show growth animation for new vocab (seeds level) + if (newVocabConstructs > 0) { + for (int i = 0; i < newVocabConstructs; i++) { + OverlayUtil.showGrowthOverlay( + context, + eventId, + level: ConstructLevelEnum.seeds, + ); + } + } + OverlayUtil.showOverlay( overlayKey: "msg_analytics_feedback_$eventId", followerAnchor: Alignment.bottomRight, diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 2efe477cd..4acdcdc0e 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -453,6 +453,7 @@ class AnalyticsDataService { ConstructLevelUpEvent( entry.key, newLevel, + update.targetID, ), ); } diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index d8074e9c3..f8f549151 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -17,6 +17,18 @@ class LevelUpdate { }); } +class ConstructLevelUpdate { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + final String? targetID; + + ConstructLevelUpdate({ + required this.constructId, + required this.level, + this.targetID, + }); +} + class AnalyticsUpdate { final List addedConstructs; final ConstructIdentifier? blockedConstruct; @@ -47,9 +59,8 @@ class AnalyticsUpdateDispatcher { final StreamController> newConstructsStream = StreamController>.broadcast(); - final StreamController> - constructLevelUpdateStream = StreamController< - MapEntry>.broadcast(); + final StreamController constructLevelUpdateStream = + StreamController.broadcast(); final StreamController> _lemmaInfoUpdateStream = StreamController< @@ -108,7 +119,7 @@ class AnalyticsUpdateDispatcher { _onBlockedConstruct(e.blockedConstruct); break; case final ConstructLevelUpEvent e: - _onConstructLevelUp(e.constructId, e.level); + _onConstructLevelUp(e.constructId, e.level, e.targetID); break; case final NewConstructsEvent e: _onNewConstruct(e.newConstructs); @@ -156,9 +167,14 @@ class AnalyticsUpdateDispatcher { void _onConstructLevelUp( ConstructIdentifier constructId, ConstructLevelEnum level, + String? targetID, ) { constructLevelUpdateStream.add( - MapEntry(constructId, level), + ConstructLevelUpdate( + constructId: constructId, + level: level, + targetID: targetID, + ), ); } diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index e00d1b96b..2e7a02ce6 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -17,9 +17,11 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent { class ConstructLevelUpEvent extends AnalyticsUpdateEvent { final ConstructIdentifier constructId; final ConstructLevelEnum level; + final String? targetID; ConstructLevelUpEvent( this.constructId, this.level, + this.targetID, ); } diff --git a/lib/pangea/analytics_misc/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart new file mode 100644 index 000000000..83cebdc27 --- /dev/null +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -0,0 +1,98 @@ +import 'dart:math'; + +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; + +class GrowthAnimation extends StatefulWidget { + final String targetID; + final ConstructLevelEnum level; + final double horizontalOffset; + final VoidCallback? onComplete; + + final int durationMs; + final double riseDistance; + + const GrowthAnimation({ + super.key, + required this.targetID, + required this.level, + this.horizontalOffset = 0, + this.onComplete, + this.durationMs = 1600, + this.riseDistance = 72, + }); + + @override + State createState() => _GrowthAnimationState(); +} + +class _GrowthAnimationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _progress; + late final Random _random; + late final double _wiggleAmplitude; + late final double _wiggleFrequency; + late final int _actualDuration; + + @override + void initState() { + super.initState(); + + // Create random variations for animation + _random = Random(); + _wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0; + _wiggleFrequency = 1.5 + _random.nextDouble() * 1.0; + _actualDuration = widget.durationMs + (_random.nextInt(400) - 200); + + _controller = AnimationController( + duration: Duration(milliseconds: _actualDuration), + vsync: this, + ); + + _progress = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + + _controller.forward().then((_) { + if (!mounted) return; + MatrixState.pAnyState + .closeOverlay("${widget.targetID}_growth_${widget.hashCode}"); + widget.onComplete?.call(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: AnimatedBuilder( + animation: _progress, + builder: (context, child) { + final clampedT = _progress.value.clamp(0.0, 1.0); + final dy = -widget.riseDistance * clampedT; + final opacity = clampedT < 0.5 ? clampedT * 2 : (1.0 - clampedT) * 2; + + final wiggle = + sin(clampedT * pi * _wiggleFrequency) * _wiggleAmplitude; + + return Opacity( + opacity: opacity, + child: Transform.translate( + offset: Offset(widget.horizontalOffset + wiggle, dy), + child: widget.level.icon(24), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index fcdaaf668..505c5e658 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -1,9 +1,7 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; +import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; import 'package:fluffychat/pangea/choreographer/choreo_constants.dart'; import 'package:fluffychat/pangea/choreographer/choreographer.dart'; @@ -13,7 +11,11 @@ import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; import 'package:fluffychat/pangea/common/widgets/anchored_overlay_widget.dart'; import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; import 'error_handler.dart'; @@ -25,6 +27,9 @@ enum OverlayPositionEnum { } class OverlayUtil { + static int _growthAnimationCount = 0; + static final Set _activeGrowthAnimations = {}; + static bool showOverlay({ required BuildContext context, required Widget child, @@ -312,6 +317,62 @@ class OverlayUtil { ); } + static void showGrowthOverlay( + BuildContext context, + String targetId, { + required ConstructLevelEnum level, + }) { + // Clean up any stale entries from dismissed overlays + _cleanupStaleGrowthAnimations(); + + final animationKey = "${targetId}_growth_${_growthAnimationCount + 1}"; + + // Calculate offset based on how many are currently active + final activeCount = _activeGrowthAnimations.length; + final double horizontalOffset; + + if (activeCount == 0) { + // Start in the middle if no animations are running + horizontalOffset = 0.0; + } else { + final side = activeCount % 2 == 0 ? 1 : -1; + final distance = ((activeCount + 1) ~/ 2) * 30.0; + horizontalOffset = side * distance; + } + + _growthAnimationCount++; + _activeGrowthAnimations.add(animationKey); + + showOverlay( + overlayKey: animationKey, + followerAnchor: Alignment.topCenter, + targetAnchor: Alignment.topCenter, + context: context, + child: GrowthAnimation( + targetID: targetId, + level: level, + horizontalOffset: horizontalOffset, + onComplete: () { + _activeGrowthAnimations.remove(animationKey); + }, + ), + transformTargetId: targetId, + closePrevOverlay: false, + backDropToDismiss: false, + ignorePointer: true, + offset: Offset(horizontalOffset, 0), + ); + } + + static void _cleanupStaleGrowthAnimations() { + // Remove any tracked animations that are no longer in the overlay system + _activeGrowthAnimations.removeWhere((key) { + final isActive = + MatrixState.pAnyState.entries.any((entry) => entry.key == key); + return !isActive; + }); + } + static void showLanguageMismatchPopup({ required BuildContext context, required String targetId, From 4dd64de133c11d5bd8075d256a20449b371764fd Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:43:15 -0500 Subject: [PATCH 3/6] simplify growth animation remove stream, calculate manually with the analytics feedback for XP, new vocab and new morphs --- lib/pages/chat/chat.dart | 86 ++++++----- .../analytics_data_service.dart | 62 ++++++-- .../analytics_update_dispatcher.dart | 34 ----- .../analytics_update_events.dart | 12 -- .../analytics_misc/construct_use_model.dart | 7 + .../analytics_misc/growth_animation.dart | 133 ++++++++++++------ lib/pangea/common/utils/overlay.dart | 50 +------ 7 files changed, 198 insertions(+), 186 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 9e1b3d752..52ad02912 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -1,9 +1,23 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_html/html.dart' as html; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; @@ -74,19 +88,6 @@ import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart' import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/share_scaffold_dialog.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:universal_html/html.dart' as html; - import '../../utils/localized_exception_extension.dart'; import 'send_file_dialog.dart'; import 'send_location_dialog.dart'; @@ -191,7 +192,6 @@ class ChatController extends State StreamSubscription? _levelSubscription; StreamSubscription? _constructsSubscription; StreamSubscription? _tokensSubscription; - StreamSubscription? _constructLevelSubscription; StreamSubscription? _botAudioSubscription; final timelineUpdateNotifier = _TimelineUpdateNotifier(); @@ -531,16 +531,6 @@ class ChatController extends State choreographer.timesDismissedIT.addListener(_onCloseIT); final updater = Matrix.of(context).analyticsDataService.updateDispatcher; - _constructLevelSubscription = - updater.constructLevelUpdateStream.stream.listen((update) { - if (update.targetID != null) { - OverlayUtil.showGrowthOverlay( - context, - update.targetID!, - level: update.level, - ); - } - }); _levelSubscription = updater.levelUpdateStream.stream.listen(_onLevelUp); _constructsSubscription = @@ -812,7 +802,6 @@ class ChatController extends State _levelSubscription?.cancel(); _botAudioSubscription?.cancel(); _constructsSubscription?.cancel(); - _constructLevelSubscription?.cancel(); _tokensSubscription?.cancel(); _router.routeInformationProvider.removeListener(_onRouteChanged); choreographer.timesDismissedIT.removeListener(_onCloseIT); @@ -2141,12 +2130,12 @@ class ChatController extends State } } - void _sendMessageAnalytics( + Future _sendMessageAnalytics( String? eventId, { PangeaRepresentation? originalSent, PangeaMessageTokens? tokensSent, ChoreoRecordModel? choreo, - }) { + }) async { // There's a listen in my_analytics_controller that decides when to auto-update // analytics based on when / how many messages the logged in user send. This // stream sends the data for newly sent messages. @@ -2169,8 +2158,8 @@ class ChatController extends State ), ]; - _showAnalyticsFeedback(constructs, eventId); - addAnalytics(constructs, eventId); + await addAnalytics(constructs, eventId); + await _showAnalyticsFeedback(constructs, eventId); } } @@ -2215,11 +2204,11 @@ class ChatController extends State final constructs = stt.constructs(roomId, eventId); if (constructs.isEmpty) return; - _showAnalyticsFeedback(constructs, eventId); - Matrix.of(context).analyticsDataService.updateService.addAnalytics( + await Matrix.of(context).analyticsDataService.updateService.addAnalytics( eventId, constructs, ); + await _showAnalyticsFeedback(constructs, eventId); } catch (e, s) { ErrorHandler.logError( e: e, @@ -2355,15 +2344,34 @@ class ChatController extends State ConstructTypeEnum.vocab, ); - // Show growth animation for new vocab (seeds level) + final newLeafConstructs = await analyticsService.getLeveledUpConstructCount( + constructs, + ConstructLevelEnum.greens, + ); + final newFlowerConstructs = + await analyticsService.getLeveledUpConstructCount( + constructs, + ConstructLevelEnum.flowers, + ); + + // Show all growth animations at once + final Map levelCounts = {}; if (newVocabConstructs > 0) { - for (int i = 0; i < newVocabConstructs; i++) { - OverlayUtil.showGrowthOverlay( - context, - eventId, - level: ConstructLevelEnum.seeds, - ); - } + levelCounts[ConstructLevelEnum.seeds] = newVocabConstructs; + } + if (newLeafConstructs > 0) { + levelCounts[ConstructLevelEnum.greens] = newLeafConstructs; + } + if (newFlowerConstructs > 0) { + levelCounts[ConstructLevelEnum.flowers] = newFlowerConstructs; + } + + if (levelCounts.isNotEmpty) { + OverlayUtil.showGrowthOverlay( + context, + eventId, + levelCounts: levelCounts, + ); } OverlayUtil.showOverlay( diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 4acdcdc0e..7d8ccdf72 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/pangea/analytics_data/analytics_database.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_database_builder.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_sync_controller.dart'; @@ -9,6 +11,7 @@ import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart'; import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart'; import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart'; +import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; @@ -21,7 +24,6 @@ import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/languages/language_model.dart'; import 'package:fluffychat/pangea/user/analytics_profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:matrix/matrix.dart'; class _AnalyticsClient { final Client client; @@ -368,6 +370,52 @@ class AnalyticsDataService { return newConstructCount; } + Future getLeveledUpConstructCount( + List newConstructs, + ConstructLevelEnum targetLevel, + ) async { + await _ensureInitialized(); + final blocked = blockedConstructs; + final uses = + newConstructs.where((c) => !blocked.contains(c.identifier)).toList(); + + final Map constructPoints = {}; + for (final use in uses) { + constructPoints[use.identifier] ??= 0; + constructPoints[use.identifier] = + constructPoints[use.identifier]! + use.xp; + } + + final constructs = await getConstructUses(constructPoints.keys.toList()); + + int leveledUpCount = 0; + for (final entry in constructPoints.entries) { + final construct = constructs[entry.key]!; + // Use uncapped points to correctly calculate previous level + // even when construct is already at max (flowers) + final uncapped = construct.uncappedPoints; + final prevUncapped = uncapped - entry.value; + final prevLevel = _getLevelFromPoints(prevUncapped); + final newLevel = _getLevelFromPoints(uncapped); + + if (prevLevel.xpNeeded < targetLevel.xpNeeded && + newLevel == targetLevel) { + leveledUpCount++; + } + } + + return leveledUpCount; + } + + ConstructLevelEnum _getLevelFromPoints(int points) { + if (points < AnalyticsConstants.xpForGreens) { + return ConstructLevelEnum.seeds; + } else if (points >= AnalyticsConstants.xpForFlower) { + return ConstructLevelEnum.flowers; + } + return ConstructLevelEnum.greens; + } + Future updateXPOffset(int offset) async { _invalidateCaches(); await _analyticsClientGetter.database.updateXPOffset(offset); @@ -445,18 +493,6 @@ class AnalyticsDataService { for (final entry in newConstructs.entries) { final prevConstruct = prevConstructs[entry.key]; if (prevConstruct == null) continue; - - final prevLevel = prevConstruct.lemmaCategory; - final newLevel = entry.value.lemmaCategory; - if (newLevel.xpNeeded > prevLevel.xpNeeded) { - events.add( - ConstructLevelUpEvent( - entry.key, - newLevel, - update.targetID, - ), - ); - } } if (update.blockedConstruct != null) { diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index f8f549151..4182fec99 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -4,7 +4,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; class LevelUpdate { @@ -17,18 +16,6 @@ class LevelUpdate { }); } -class ConstructLevelUpdate { - final ConstructIdentifier constructId; - final ConstructLevelEnum level; - final String? targetID; - - ConstructLevelUpdate({ - required this.constructId, - required this.level, - this.targetID, - }); -} - class AnalyticsUpdate { final List addedConstructs; final ConstructIdentifier? blockedConstruct; @@ -59,9 +46,6 @@ class AnalyticsUpdateDispatcher { final StreamController> newConstructsStream = StreamController>.broadcast(); - final StreamController constructLevelUpdateStream = - StreamController.broadcast(); - final StreamController> _lemmaInfoUpdateStream = StreamController< MapEntry>.broadcast(); @@ -74,7 +58,6 @@ class AnalyticsUpdateDispatcher { unlockedConstructsStream.close(); levelUpdateStream.close(); _lemmaInfoUpdateStream.close(); - constructLevelUpdateStream.close(); } Stream lemmaUpdateStream( @@ -118,9 +101,6 @@ class AnalyticsUpdateDispatcher { case final ConstructBlockedEvent e: _onBlockedConstruct(e.blockedConstruct); break; - case final ConstructLevelUpEvent e: - _onConstructLevelUp(e.constructId, e.level, e.targetID); - break; case final NewConstructsEvent e: _onNewConstruct(e.newConstructs); break; @@ -164,20 +144,6 @@ class AnalyticsUpdateDispatcher { constructUpdateStream.add(update); } - void _onConstructLevelUp( - ConstructIdentifier constructId, - ConstructLevelEnum level, - String? targetID, - ) { - constructLevelUpdateStream.add( - ConstructLevelUpdate( - constructId: constructId, - level: level, - targetID: targetID, - ), - ); - } - void _onNewConstruct(Set constructIds) { if (constructIds.isEmpty) return; newConstructsStream.add(constructIds); diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index 2e7a02ce6..1d79afb02 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -1,5 +1,4 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; sealed class AnalyticsUpdateEvent {} @@ -14,17 +13,6 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent { MorphUnlockedEvent(this.unlocked); } -class ConstructLevelUpEvent extends AnalyticsUpdateEvent { - final ConstructIdentifier constructId; - final ConstructLevelEnum level; - final String? targetID; - ConstructLevelUpEvent( - this.constructId, - this.level, - this.targetID, - ); -} - class XPGainedEvent extends AnalyticsUpdateEvent { final int points; final String? targetID; diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index e3a3ddda5..b1b81d044 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -37,6 +37,13 @@ class ConstructUses { ); } + int get uncappedPoints { + return _uses.fold( + 0, + (total, use) => total + use.xp, + ); + } + DateTime? get lastUsed => _uses.lastOrNull?.timeStamp; DateTime? get cappedLastUse => cappedUses.lastOrNull?.timeStamp; diff --git a/lib/pangea/analytics_misc/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart index 83cebdc27..2c390eeb7 100644 --- a/lib/pangea/analytics_misc/growth_animation.dart +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -1,25 +1,37 @@ import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; + +class _GrowthItem { + final ConstructLevelEnum level; + final double horizontalOffset; + final double wiggleAmplitude; + final double wiggleFrequency; + final int delayMs; + + _GrowthItem({ + required this.level, + required this.horizontalOffset, + required this.wiggleAmplitude, + required this.wiggleFrequency, + required this.delayMs, + }); +} class GrowthAnimation extends StatefulWidget { final String targetID; - final ConstructLevelEnum level; - final double horizontalOffset; - final VoidCallback? onComplete; - - final int durationMs; + final Map levelCounts; + final int itemDurationMs; final double riseDistance; const GrowthAnimation({ super.key, required this.targetID, - required this.level, - this.horizontalOffset = 0, - this.onComplete, - this.durationMs = 1600, + required this.levelCounts, + this.itemDurationMs = 1600, this.riseDistance = 72, }); @@ -30,40 +42,59 @@ class GrowthAnimation extends StatefulWidget { class _GrowthAnimationState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final Animation _progress; - late final Random _random; - late final double _wiggleAmplitude; - late final double _wiggleFrequency; - late final int _actualDuration; + late final List<_GrowthItem> _items; + late final int _totalDurationMs; + final Random _random = Random(); + + static const int _staggerDelayMs = 50; @override void initState() { super.initState(); - - // Create random variations for animation - _random = Random(); - _wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0; - _wiggleFrequency = 1.5 + _random.nextDouble() * 1.0; - _actualDuration = widget.durationMs + (_random.nextInt(400) - 200); + _items = _buildItems(); + final maxDelay = _items.isEmpty ? 0 : _items.last.delayMs; + _totalDurationMs = maxDelay + widget.itemDurationMs; _controller = AnimationController( - duration: Duration(milliseconds: _actualDuration), + duration: Duration(milliseconds: _totalDurationMs), vsync: this, ); - _progress = CurvedAnimation( - parent: _controller, - curve: Curves.easeOut, - ); - _controller.forward().then((_) { if (!mounted) return; - MatrixState.pAnyState - .closeOverlay("${widget.targetID}_growth_${widget.hashCode}"); - widget.onComplete?.call(); + MatrixState.pAnyState.closeOverlay("${widget.targetID}_growth"); }); } + List<_GrowthItem> _buildItems() { + final items = <_GrowthItem>[]; + int index = 0; + + for (final level in [ + ConstructLevelEnum.seeds, + ConstructLevelEnum.greens, + ConstructLevelEnum.flowers, + ]) { + final count = widget.levelCounts[level] ?? 0; + for (int i = 0; i < count; i++) { + final side = index % 2 == 0 ? 1 : -1; + final distance = ((index + 1) ~/ 2) * 30.0; + + items.add( + _GrowthItem( + level: level, + horizontalOffset: side * distance, + wiggleAmplitude: 4.0 + _random.nextDouble() * 4.0, + wiggleFrequency: 1.5 + _random.nextDouble() * 1.0, + delayMs: index * _staggerDelayMs, + ), + ); + index++; + } + } + return items; + } + @override void dispose() { _controller.dispose(); @@ -72,27 +103,41 @@ class _GrowthAnimationState extends State @override Widget build(BuildContext context) { + if (_items.isEmpty) return const SizedBox.shrink(); + return Material( type: MaterialType.transparency, child: AnimatedBuilder( - animation: _progress, + animation: _controller, builder: (context, child) { - final clampedT = _progress.value.clamp(0.0, 1.0); - final dy = -widget.riseDistance * clampedT; - final opacity = clampedT < 0.5 ? clampedT * 2 : (1.0 - clampedT) * 2; - - final wiggle = - sin(clampedT * pi * _wiggleFrequency) * _wiggleAmplitude; - - return Opacity( - opacity: opacity, - child: Transform.translate( - offset: Offset(widget.horizontalOffset + wiggle, dy), - child: widget.level.icon(24), - ), + return Stack( + clipBehavior: Clip.none, + children: _items.map(_buildItem).toList(), ); }, ), ); } + + Widget _buildItem(_GrowthItem item) { + final elapsedMs = _controller.value * _totalDurationMs; + final itemElapsedMs = + (elapsedMs - item.delayMs).clamp(0.0, widget.itemDurationMs.toDouble()); + final t = (itemElapsedMs / widget.itemDurationMs).clamp(0.0, 1.0); + + if (t <= 0) return const SizedBox.shrink(); + + final curvedT = Curves.easeOut.transform(t); + final dy = -widget.riseDistance * curvedT; + final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2; + final wiggle = sin(t * pi * item.wiggleFrequency) * item.wiggleAmplitude; + + return Transform.translate( + offset: Offset(item.horizontalOffset + wiggle, dy), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: item.level.icon(24), + ), + ); + } } diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index 505c5e658..ff6e14f4a 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -1,5 +1,8 @@ import 'dart:developer'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; @@ -13,9 +16,6 @@ import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; import 'error_handler.dart'; @@ -27,9 +27,6 @@ enum OverlayPositionEnum { } class OverlayUtil { - static int _growthAnimationCount = 0; - static final Set _activeGrowthAnimations = {}; - static bool showOverlay({ required BuildContext context, required Widget child, @@ -320,59 +317,24 @@ class OverlayUtil { static void showGrowthOverlay( BuildContext context, String targetId, { - required ConstructLevelEnum level, + required Map levelCounts, }) { - // Clean up any stale entries from dismissed overlays - _cleanupStaleGrowthAnimations(); - - final animationKey = "${targetId}_growth_${_growthAnimationCount + 1}"; - - // Calculate offset based on how many are currently active - final activeCount = _activeGrowthAnimations.length; - final double horizontalOffset; - - if (activeCount == 0) { - // Start in the middle if no animations are running - horizontalOffset = 0.0; - } else { - final side = activeCount % 2 == 0 ? 1 : -1; - final distance = ((activeCount + 1) ~/ 2) * 30.0; - horizontalOffset = side * distance; - } - - _growthAnimationCount++; - _activeGrowthAnimations.add(animationKey); - showOverlay( - overlayKey: animationKey, + overlayKey: "${targetId}_growth", followerAnchor: Alignment.topCenter, targetAnchor: Alignment.topCenter, context: context, child: GrowthAnimation( targetID: targetId, - level: level, - horizontalOffset: horizontalOffset, - onComplete: () { - _activeGrowthAnimations.remove(animationKey); - }, + levelCounts: levelCounts, ), transformTargetId: targetId, closePrevOverlay: false, backDropToDismiss: false, ignorePointer: true, - offset: Offset(horizontalOffset, 0), ); } - static void _cleanupStaleGrowthAnimations() { - // Remove any tracked animations that are no longer in the overlay system - _activeGrowthAnimations.removeWhere((key) { - final isActive = - MatrixState.pAnyState.entries.any((entry) => entry.key == key); - return !isActive; - }); - } - static void showLanguageMismatchPopup({ required BuildContext context, required String targetId, From 68a8733976dfd7fec28cfa4ceaaa8a4528910e99 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:54:58 -0500 Subject: [PATCH 4/6] cleanup --- lib/pangea/analytics_data/analytics_data_service.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 7d8ccdf72..83dac01fd 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -490,11 +490,6 @@ class AnalyticsDataService { events.add(MorphUnlockedEvent(newUnlockedMorphs)); } - for (final entry in newConstructs.entries) { - final prevConstruct = prevConstructs[entry.key]; - if (prevConstruct == null) continue; - } - if (update.blockedConstruct != null) { events.add(ConstructBlockedEvent(update.blockedConstruct!)); } From c3d6a9996ef2506fbb109e1c240371c550f3d180 Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:50:12 -0500 Subject: [PATCH 5/6] fix: use event stream for construct level animation --- lib/pages/chat/chat.dart | 31 ---- .../analytics_data_service.dart | 64 ++------ .../analytics_update_dispatcher.dart | 34 ++++ .../analytics_update_events.dart | 12 ++ .../analytics_updater_mixin.dart | 11 ++ .../analytics_misc/construct_use_model.dart | 7 - .../analytics_misc/growth_animation.dart | 149 ++++++------------ lib/pangea/common/utils/overlay.dart | 23 +-- 8 files changed, 138 insertions(+), 193 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 52ad02912..865346746 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -52,7 +52,6 @@ import 'package:fluffychat/pangea/common/utils/firebase_analytics.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; -import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'; @@ -2344,36 +2343,6 @@ class ChatController extends State ConstructTypeEnum.vocab, ); - final newLeafConstructs = await analyticsService.getLeveledUpConstructCount( - constructs, - ConstructLevelEnum.greens, - ); - final newFlowerConstructs = - await analyticsService.getLeveledUpConstructCount( - constructs, - ConstructLevelEnum.flowers, - ); - - // Show all growth animations at once - final Map levelCounts = {}; - if (newVocabConstructs > 0) { - levelCounts[ConstructLevelEnum.seeds] = newVocabConstructs; - } - if (newLeafConstructs > 0) { - levelCounts[ConstructLevelEnum.greens] = newLeafConstructs; - } - if (newFlowerConstructs > 0) { - levelCounts[ConstructLevelEnum.flowers] = newFlowerConstructs; - } - - if (levelCounts.isNotEmpty) { - OverlayUtil.showGrowthOverlay( - context, - eventId, - levelCounts: levelCounts, - ); - } - OverlayUtil.showOverlay( overlayKey: "msg_analytics_feedback_$eventId", followerAnchor: Alignment.bottomRight, diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 83dac01fd..5c10374da 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -11,7 +11,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart'; import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart'; import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart'; import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart'; -import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; @@ -370,52 +369,6 @@ class AnalyticsDataService { return newConstructCount; } - Future getLeveledUpConstructCount( - List newConstructs, - ConstructLevelEnum targetLevel, - ) async { - await _ensureInitialized(); - final blocked = blockedConstructs; - final uses = - newConstructs.where((c) => !blocked.contains(c.identifier)).toList(); - - final Map constructPoints = {}; - for (final use in uses) { - constructPoints[use.identifier] ??= 0; - constructPoints[use.identifier] = - constructPoints[use.identifier]! + use.xp; - } - - final constructs = await getConstructUses(constructPoints.keys.toList()); - - int leveledUpCount = 0; - for (final entry in constructPoints.entries) { - final construct = constructs[entry.key]!; - // Use uncapped points to correctly calculate previous level - // even when construct is already at max (flowers) - final uncapped = construct.uncappedPoints; - final prevUncapped = uncapped - entry.value; - final prevLevel = _getLevelFromPoints(prevUncapped); - final newLevel = _getLevelFromPoints(uncapped); - - if (prevLevel.xpNeeded < targetLevel.xpNeeded && - newLevel == targetLevel) { - leveledUpCount++; - } - } - - return leveledUpCount; - } - - ConstructLevelEnum _getLevelFromPoints(int points) { - if (points < AnalyticsConstants.xpForGreens) { - return ConstructLevelEnum.seeds; - } else if (points >= AnalyticsConstants.xpForFlower) { - return ConstructLevelEnum.flowers; - } - return ConstructLevelEnum.greens; - } - Future updateXPOffset(int offset) async { _invalidateCaches(); await _analyticsClientGetter.database.updateXPOffset(offset); @@ -490,6 +443,23 @@ class AnalyticsDataService { events.add(MorphUnlockedEvent(newUnlockedMorphs)); } + for (final entry in newConstructs.entries) { + final prevConstruct = prevConstructs[entry.key]; + if (prevConstruct == null) continue; + + final prevLevel = prevConstruct.lemmaCategory; + final newLevel = entry.value.lemmaCategory; + if (newLevel.xpNeeded > prevLevel.xpNeeded) { + events.add( + ConstructLevelUpEvent( + entry.key, + newLevel, + update.targetID, + ), + ); + } + } + if (update.blockedConstruct != null) { events.add(ConstructBlockedEvent(update.blockedConstruct!)); } diff --git a/lib/pangea/analytics_data/analytics_update_dispatcher.dart b/lib/pangea/analytics_data/analytics_update_dispatcher.dart index 4182fec99..922d6637f 100644 --- a/lib/pangea/analytics_data/analytics_update_dispatcher.dart +++ b/lib/pangea/analytics_data/analytics_update_dispatcher.dart @@ -4,6 +4,7 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart'; class LevelUpdate { @@ -28,6 +29,18 @@ class AnalyticsUpdate { }); } +class ConstructLevelUpdate { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + final String? targetID; + + ConstructLevelUpdate({ + required this.constructId, + required this.level, + this.targetID, + }); +} + class AnalyticsUpdateDispatcher { final AnalyticsDataService dataService; @@ -46,6 +59,9 @@ class AnalyticsUpdateDispatcher { final StreamController> newConstructsStream = StreamController>.broadcast(); + final StreamController constructLevelUpdateStream = + StreamController.broadcast(); + final StreamController> _lemmaInfoUpdateStream = StreamController< MapEntry>.broadcast(); @@ -57,6 +73,7 @@ class AnalyticsUpdateDispatcher { activityAnalyticsStream.close(); unlockedConstructsStream.close(); levelUpdateStream.close(); + constructLevelUpdateStream.close(); _lemmaInfoUpdateStream.close(); } @@ -101,6 +118,9 @@ class AnalyticsUpdateDispatcher { case final ConstructBlockedEvent e: _onBlockedConstruct(e.blockedConstruct); break; + case final ConstructLevelUpEvent e: + _onConstructLevelUp(e.constructId, e.level, e.targetID); + break; case final NewConstructsEvent e: _onNewConstruct(e.newConstructs); break; @@ -137,6 +157,20 @@ class AnalyticsUpdateDispatcher { constructUpdateStream.add(update); } + void _onConstructLevelUp( + ConstructIdentifier constructId, + ConstructLevelEnum level, + String? targetID, + ) { + constructLevelUpdateStream.add( + ConstructLevelUpdate( + constructId: constructId, + level: level, + targetID: targetID, + ), + ); + } + void _onBlockedConstruct(ConstructIdentifier constructId) { final update = AnalyticsStreamUpdate( blockedConstruct: constructId, diff --git a/lib/pangea/analytics_data/analytics_update_events.dart b/lib/pangea/analytics_data/analytics_update_events.dart index 1d79afb02..2e7a02ce6 100644 --- a/lib/pangea/analytics_data/analytics_update_events.dart +++ b/lib/pangea/analytics_data/analytics_update_events.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; sealed class AnalyticsUpdateEvent {} @@ -13,6 +14,17 @@ class MorphUnlockedEvent extends AnalyticsUpdateEvent { MorphUnlockedEvent(this.unlocked); } +class ConstructLevelUpEvent extends AnalyticsUpdateEvent { + final ConstructIdentifier constructId; + final ConstructLevelEnum level; + final String? targetID; + ConstructLevelUpEvent( + this.constructId, + this.level, + this.targetID, + ); +} + class XPGainedEvent extends AnalyticsUpdateEvent { final int points; final String? targetID; diff --git a/lib/pangea/analytics_data/analytics_updater_mixin.dart b/lib/pangea/analytics_data/analytics_updater_mixin.dart index fa90aab51..633cc77f1 100644 --- a/lib/pangea/analytics_data/analytics_updater_mixin.dart +++ b/lib/pangea/analytics_data/analytics_updater_mixin.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart'; +import 'package:fluffychat/pangea/analytics_data/analytics_update_dispatcher.dart'; import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/widgets/matrix.dart'; mixin AnalyticsUpdater on State { StreamSubscription? _analyticsSubscription; + StreamSubscription? _constructLevelSubscription; @override void initState() { @@ -16,11 +18,14 @@ mixin AnalyticsUpdater on State { final updater = Matrix.of(context).analyticsDataService.updateDispatcher; _analyticsSubscription = updater.constructUpdateStream.stream.listen(_onAnalyticsUpdate); + _constructLevelSubscription = + updater.constructLevelUpdateStream.stream.listen(_onConstructLevelUp); } @override void dispose() { _analyticsSubscription?.cancel(); + _constructLevelSubscription?.cancel(); super.dispose(); } @@ -38,4 +43,10 @@ mixin AnalyticsUpdater on State { OverlayUtil.showPointsGained(update.targetID!, update.points, context); } } + + void _onConstructLevelUp(ConstructLevelUpdate update) { + if (update.targetID != null) { + OverlayUtil.showGrowthAnimation(context, update.targetID!, update.level); + } + } } diff --git a/lib/pangea/analytics_misc/construct_use_model.dart b/lib/pangea/analytics_misc/construct_use_model.dart index b1b81d044..e3a3ddda5 100644 --- a/lib/pangea/analytics_misc/construct_use_model.dart +++ b/lib/pangea/analytics_misc/construct_use_model.dart @@ -37,13 +37,6 @@ class ConstructUses { ); } - int get uncappedPoints { - return _uses.fold( - 0, - (total, use) => total + use.xp, - ); - } - DateTime? get lastUsed => _uses.lastOrNull?.timeStamp; DateTime? get cappedLastUse => cappedUses.lastOrNull?.timeStamp; diff --git a/lib/pangea/analytics_misc/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart index 2c390eeb7..7f36a30da 100644 --- a/lib/pangea/analytics_misc/growth_animation.dart +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -1,38 +1,37 @@ import 'dart:math'; -import 'package:flutter/material.dart'; - import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; -class _GrowthItem { - final ConstructLevelEnum level; - final double horizontalOffset; - final double wiggleAmplitude; - final double wiggleFrequency; - final int delayMs; +/// Tracks active growth animations for offset calculation +class GrowthAnimationTracker { + static int _activeCount = 0; - _GrowthItem({ - required this.level, - required this.horizontalOffset, - required this.wiggleAmplitude, - required this.wiggleFrequency, - required this.delayMs, - }); + static int get activeCount => _activeCount; + + static double? startAnimation() { + if (_activeCount >= 5) return null; + final index = _activeCount; + _activeCount++; + if (index == 0) return 0; + final side = index.isOdd ? 1 : -1; + return side * ((index + 1) ~/ 2) * 20.0; + } + + static void endAnimation() { + _activeCount = (_activeCount - 1).clamp(0, 999); + } } class GrowthAnimation extends StatefulWidget { final String targetID; - final Map levelCounts; - final int itemDurationMs; - final double riseDistance; + final ConstructLevelEnum level; const GrowthAnimation({ super.key, required this.targetID, - required this.levelCounts, - this.itemDurationMs = 1600, - this.riseDistance = 72, + required this.level, }); @override @@ -42,102 +41,56 @@ class GrowthAnimation extends StatefulWidget { class _GrowthAnimationState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final List<_GrowthItem> _items; - late final int _totalDurationMs; + late final double? _horizontalOffset; + late final double _wiggleAmplitude; + late final double _wiggleFrequency; final Random _random = Random(); - static const int _staggerDelayMs = 50; + static const _durationMs = 1600; + static const _riseDistance = 72.0; @override void initState() { super.initState(); - _items = _buildItems(); - final maxDelay = _items.isEmpty ? 0 : _items.last.delayMs; - _totalDurationMs = maxDelay + widget.itemDurationMs; + _horizontalOffset = GrowthAnimationTracker.startAnimation(); + _wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0; + _wiggleFrequency = 1.5 + _random.nextDouble() * 1.0; _controller = AnimationController( - duration: Duration(milliseconds: _totalDurationMs), + duration: const Duration(milliseconds: _durationMs), vsync: this, - ); - - _controller.forward().then((_) { - if (!mounted) return; - MatrixState.pAnyState.closeOverlay("${widget.targetID}_growth"); - }); - } - - List<_GrowthItem> _buildItems() { - final items = <_GrowthItem>[]; - int index = 0; - - for (final level in [ - ConstructLevelEnum.seeds, - ConstructLevelEnum.greens, - ConstructLevelEnum.flowers, - ]) { - final count = widget.levelCounts[level] ?? 0; - for (int i = 0; i < count; i++) { - final side = index % 2 == 0 ? 1 : -1; - final distance = ((index + 1) ~/ 2) * 30.0; - - items.add( - _GrowthItem( - level: level, - horizontalOffset: side * distance, - wiggleAmplitude: 4.0 + _random.nextDouble() * 4.0, - wiggleFrequency: 1.5 + _random.nextDouble() * 1.0, - delayMs: index * _staggerDelayMs, - ), - ); - index++; - } - } - return items; + )..forward().then((_) { + if (mounted) { + MatrixState.pAnyState.closeOverlay(widget.targetID); + } + }); } @override void dispose() { + GrowthAnimationTracker.endAnimation(); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - if (_items.isEmpty) return const SizedBox.shrink(); - - return Material( - type: MaterialType.transparency, - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) { - return Stack( - clipBehavior: Clip.none, - children: _items.map(_buildItem).toList(), - ); - }, - ), - ); - } - - Widget _buildItem(_GrowthItem item) { - final elapsedMs = _controller.value * _totalDurationMs; - final itemElapsedMs = - (elapsedMs - item.delayMs).clamp(0.0, widget.itemDurationMs.toDouble()); - final t = (itemElapsedMs / widget.itemDurationMs).clamp(0.0, 1.0); - - if (t <= 0) return const SizedBox.shrink(); - - final curvedT = Curves.easeOut.transform(t); - final dy = -widget.riseDistance * curvedT; - final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2; - final wiggle = sin(t * pi * item.wiggleFrequency) * item.wiggleAmplitude; - - return Transform.translate( - offset: Offset(item.horizontalOffset + wiggle, dy), - child: Opacity( - opacity: opacity.clamp(0.0, 1.0), - child: item.level.icon(24), - ), + if (_horizontalOffset == null) return const SizedBox.shrink(); + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + final dy = -_riseDistance * Curves.easeOut.transform(t); + final opacity = t < 0.5 ? t * 2 : (1.0 - t) * 2; + final wiggle = sin(t * pi * _wiggleFrequency) * _wiggleAmplitude; + return Transform.translate( + offset: Offset(_horizontalOffset! + wiggle, dy), + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: widget.level.icon(24), + ), + ); + }, ); } } diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index ff6e14f4a..b9ffabf2d 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -1,8 +1,5 @@ import 'dart:developer'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; @@ -16,6 +13,9 @@ import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; import 'error_handler.dart'; @@ -314,19 +314,22 @@ class OverlayUtil { ); } - static void showGrowthOverlay( + static int _growthAnimationCounter = 0; + + static void showGrowthAnimation( BuildContext context, - String targetId, { - required Map levelCounts, - }) { + String targetId, + ConstructLevelEnum level, + ) { + final overlayKey = "${targetId}_growth_${_growthAnimationCounter++}"; showOverlay( - overlayKey: "${targetId}_growth", + overlayKey: overlayKey, followerAnchor: Alignment.topCenter, targetAnchor: Alignment.topCenter, context: context, child: GrowthAnimation( - targetID: targetId, - levelCounts: levelCounts, + targetID: overlayKey, + level: level, ), transformTargetId: targetId, closePrevOverlay: false, From dc9e6ab5af6d94303a85362ad787d192d68a588b Mon Sep 17 00:00:00 2001 From: Ava Shilling <165050625+avashilling@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:55:35 -0500 Subject: [PATCH 6/6] remove async function for analytics in chat and sort imports --- lib/pages/chat/chat.dart | 12 ++++++------ lib/pangea/analytics_misc/growth_animation.dart | 3 ++- lib/pangea/common/utils/overlay.dart | 6 +++--- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 865346746..5e6be0394 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2129,12 +2129,12 @@ class ChatController extends State } } - Future _sendMessageAnalytics( + void _sendMessageAnalytics( String? eventId, { PangeaRepresentation? originalSent, PangeaMessageTokens? tokensSent, ChoreoRecordModel? choreo, - }) async { + }) { // There's a listen in my_analytics_controller that decides when to auto-update // analytics based on when / how many messages the logged in user send. This // stream sends the data for newly sent messages. @@ -2157,8 +2157,8 @@ class ChatController extends State ), ]; - await addAnalytics(constructs, eventId); - await _showAnalyticsFeedback(constructs, eventId); + _showAnalyticsFeedback(constructs, eventId); + addAnalytics(constructs, eventId); } } @@ -2203,11 +2203,11 @@ class ChatController extends State final constructs = stt.constructs(roomId, eventId); if (constructs.isEmpty) return; - await Matrix.of(context).analyticsDataService.updateService.addAnalytics( + _showAnalyticsFeedback(constructs, eventId); + Matrix.of(context).analyticsDataService.updateService.addAnalytics( eventId, constructs, ); - await _showAnalyticsFeedback(constructs, eventId); } catch (e, s) { ErrorHandler.logError( e: e, diff --git a/lib/pangea/analytics_misc/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart index 7f36a30da..7cd42c4fa 100644 --- a/lib/pangea/analytics_misc/growth_animation.dart +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -1,8 +1,9 @@ import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; /// Tracks active growth animations for offset calculation class GrowthAnimationTracker { diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index b9ffabf2d..de3187b29 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -1,5 +1,8 @@ import 'dart:developer'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/growth_animation.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; @@ -13,9 +16,6 @@ import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; import 'package:fluffychat/pangea/learning_settings/language_mismatch_popup.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; import 'error_handler.dart';