From 66e4cbc6afb18249b4cdf43181c3f4cc31fb39d5 Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Mon, 7 Jul 2025 09:43:52 -0400 Subject: [PATCH] feat: add star and morph icon rain when new grammar concept unlocked (#3338) --- .../chat/utils/unlocked_morphs_snackbar.dart | 15 +- lib/pangea/toolbar/widgets/icon_rain.dart | 258 ++++++++++++++++++ 2 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 lib/pangea/toolbar/widgets/icon_rain.dart diff --git a/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart b/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart index de197503c..f8ae1d70c 100644 --- a/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart +++ b/lib/pangea/chat/utils/unlocked_morphs_snackbar.dart @@ -9,12 +9,12 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; -import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart'; import 'package:fluffychat/pangea/common/utils/overlay.dart'; import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/toolbar/widgets/icon_rain.dart'; import 'package:fluffychat/widgets/matrix.dart'; class ConstructNotificationUtil { @@ -124,10 +124,15 @@ class ConstructNotificationOverlayState followerAnchor: Alignment.topCenter, targetAnchor: Alignment.topCenter, context: context, - child: PointsGainedAnimation( - points: 50, - targetID: "${widget.construct.string}_notification", - invert: true, + child: IconRain( + addStars: true, + icon: MorphIcon( + size: const Size(8, 8), + morphFeature: MorphFeaturesEnumExtension.fromString( + widget.construct.category, + ), + morphTag: widget.construct.lemma, + ), ), transformTargetId: "${widget.construct.string}_notification", closePrevOverlay: false, diff --git a/lib/pangea/toolbar/widgets/icon_rain.dart b/lib/pangea/toolbar/widgets/icon_rain.dart new file mode 100644 index 000000000..7b9d41ada --- /dev/null +++ b/lib/pangea/toolbar/widgets/icon_rain.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class IconRain extends StatefulWidget { + final Widget icon; + final int burstCount; + final int taperCount; + final Duration burstDuration; + final Duration taperDuration; + final Duration fallDuration; + final double swayAmplitude; // in pixels + final double swayFrequency; // in Hz + final bool addStars; + + const IconRain({ + super.key, + required this.icon, + this.burstCount = 20, + this.taperCount = 8, + this.burstDuration = const Duration(milliseconds: 300), + this.taperDuration = const Duration(milliseconds: 900), + this.fallDuration = const Duration(seconds: 2), + this.swayAmplitude = 32.0, + this.swayFrequency = 0.8, + this.addStars = false, + }); + + @override + State createState() => _IconRainState(); +} + +class _IconRainState extends State with TickerProviderStateMixin { + final List<_FallingIcon> _icons = []; + final Random _random = Random(); + Timer? _burstTimer; + Timer? _taperTimer; + int _burstSpawned = 0; + int _taperSpawned = 0; + + @override + void initState() { + super.initState(); + _startBurst(); + } + + Widget _getIcon() { + if (widget.addStars && _random.nextBool()) { + return const Text('⭐', style: TextStyle(fontSize: 12)); + } + return widget.icon; + } + + void _startBurst() { + final burstInterval = widget.burstDuration ~/ widget.burstCount; + _burstTimer = Timer.periodic(burstInterval, (timer) { + if (!mounted) return; + setState(() { + _icons.add( + _FallingIcon( + key: UniqueKey(), + icon: _getIcon(), + startX: _random.nextDouble(), + duration: widget.fallDuration, + swayAmplitude: widget.swayAmplitude, + swayFrequency: widget.swayFrequency, + fadeMidpoint: 0.4 + _random.nextDouble() * 0.2, // 40-60% down + onComplete: () { + setState(() { + _icons.removeWhere((i) => i.key == _icons.first.key); + }); + }, + ), + ); + _burstSpawned++; + if (_burstSpawned >= widget.burstCount) { + _burstTimer?.cancel(); + _startTaper(); + } + }); + }); + } + + void _startTaper() { + if (widget.taperCount == 0) return; + final taperInterval = widget.taperDuration ~/ widget.taperCount; + _taperTimer = Timer.periodic(taperInterval, (timer) { + if (!mounted) return; + setState(() { + _icons.add( + _FallingIcon( + key: UniqueKey(), + icon: _getIcon(), + startX: _random.nextDouble(), + duration: widget.fallDuration, + swayAmplitude: widget.swayAmplitude, + swayFrequency: widget.swayFrequency, + fadeMidpoint: 0.4 + _random.nextDouble() * 0.2, // 40-60% down + onComplete: () { + setState(() { + _icons.removeWhere((i) => i.key == _icons.first.key); + }); + }, + ), + ); + _taperSpawned++; + if (_taperSpawned >= widget.taperCount) { + _taperTimer?.cancel(); + } + }); + }); + } + + @override + void dispose() { + _burstTimer?.cancel(); + _taperTimer?.cancel(); + _icons.clear(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: _icons.map((icon) { + return icon.build( + context, + constraints.maxWidth, + constraints.maxHeight, + ); + }).toList(), + ); + }, + ); + } +} + +class _FallingIcon { + final Key key; + final Widget icon; + final double startX; + final Duration duration; + final double swayAmplitude; + final double swayFrequency; + final double fadeMidpoint; + final VoidCallback onComplete; + + _FallingIcon({ + required this.key, + required this.icon, + required this.startX, + required this.duration, + required this.swayAmplitude, + required this.swayFrequency, + required this.fadeMidpoint, + required this.onComplete, + }); + + Widget build(BuildContext context, double maxWidth, double maxHeight) { + return _AnimatedFallingIcon( + key: key, + icon: icon, + startX: startX, + duration: duration, + maxWidth: maxWidth, + maxHeight: maxHeight, + swayAmplitude: swayAmplitude, + swayFrequency: swayFrequency, + fadeMidpoint: fadeMidpoint, + onComplete: onComplete, + ); + } +} + +class _AnimatedFallingIcon extends StatefulWidget { + final Widget icon; + final double startX; + final Duration duration; + final double maxWidth; + final double maxHeight; + final double swayAmplitude; + final double swayFrequency; + final double fadeMidpoint; + final VoidCallback onComplete; + + const _AnimatedFallingIcon({ + super.key, + required this.icon, + required this.startX, + required this.duration, + required this.maxWidth, + required this.maxHeight, + required this.swayAmplitude, + required this.swayFrequency, + required this.fadeMidpoint, + required this.onComplete, + }); + + @override + State<_AnimatedFallingIcon> createState() => _AnimatedFallingIconState(); +} + +class _AnimatedFallingIconState extends State<_AnimatedFallingIcon> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + _animation = Tween(begin: -40, end: widget.maxHeight + 40).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeIn), + ); + _controller.forward().then((_) => widget.onComplete()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final progress = _controller.value; + final sway = widget.swayAmplitude * + sin( + 2 * pi * widget.swayFrequency * progress + widget.startX * 2 * pi, + ); + // Fade out after fadeMidpoint + double opacity = 1.0; + if (progress > widget.fadeMidpoint) { + final fadeProgress = + (progress - widget.fadeMidpoint) / (1 - widget.fadeMidpoint); + opacity = 1.0 - fadeProgress.clamp(0.0, 1.0); + } + return Positioned( + left: widget.startX * (widget.maxWidth - 40) + sway, + top: _animation.value, + child: Opacity( + opacity: opacity, + child: SizedBox(width: 40, height: 40, child: widget.icon), + ), + ); + }, + ); + } +}