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] 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,