diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 31032b887..5c10374da 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -19,6 +19,7 @@ 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'; @@ -442,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/growth_animation.dart b/lib/pangea/analytics_misc/growth_animation.dart new file mode 100644 index 000000000..7cd42c4fa --- /dev/null +++ b/lib/pangea/analytics_misc/growth_animation.dart @@ -0,0 +1,97 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/constructs/construct_level_enum.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Tracks active growth animations for offset calculation +class GrowthAnimationTracker { + static int _activeCount = 0; + + 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 ConstructLevelEnum level; + + const GrowthAnimation({ + super.key, + required this.targetID, + required this.level, + }); + + @override + State createState() => _GrowthAnimationState(); +} + +class _GrowthAnimationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final double? _horizontalOffset; + late final double _wiggleAmplitude; + late final double _wiggleFrequency; + final Random _random = Random(); + + static const _durationMs = 1600; + static const _riseDistance = 72.0; + + @override + void initState() { + super.initState(); + _horizontalOffset = GrowthAnimationTracker.startAnimation(); + _wiggleAmplitude = 4.0 + _random.nextDouble() * 4.0; + _wiggleFrequency = 1.5 + _random.nextDouble() * 1.0; + + _controller = AnimationController( + duration: const Duration(milliseconds: _durationMs), + vsync: this, + )..forward().then((_) { + if (mounted) { + MatrixState.pAnyState.closeOverlay(widget.targetID); + } + }); + } + + @override + void dispose() { + GrowthAnimationTracker.endAnimation(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + 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 fcdaaf668..de3187b29 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -4,6 +4,7 @@ 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,6 +14,7 @@ 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 '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; @@ -312,6 +314,30 @@ class OverlayUtil { ); } + static int _growthAnimationCounter = 0; + + static void showGrowthAnimation( + BuildContext context, + String targetId, + ConstructLevelEnum level, + ) { + final overlayKey = "${targetId}_growth_${_growthAnimationCounter++}"; + showOverlay( + overlayKey: overlayKey, + followerAnchor: Alignment.topCenter, + targetAnchor: Alignment.topCenter, + context: context, + child: GrowthAnimation( + targetID: overlayKey, + level: level, + ), + transformTargetId: targetId, + closePrevOverlay: false, + backDropToDismiss: false, + ignorePointer: true, + ); + } + static void showLanguageMismatchPopup({ required BuildContext context, required String targetId,