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,