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,