seed spins in and fades to top left to draw attention to vocab increment

+ minor refactoring
This commit is contained in:
avashilling 2025-07-01 17:03:33 -04:00
parent 16ea5ea563
commit 9afee1cca1
3 changed files with 106 additions and 93 deletions

View file

@ -49,12 +49,10 @@ class ProgressIndicatorBadge extends StatelessWidget {
class _AnimatedFloatingNumber extends StatefulWidget {
final int number;
final ProgressIndicatorEnum indicator;
final Duration duration;
const _AnimatedFloatingNumber({
required this.number,
required this.indicator,
this.duration = const Duration(milliseconds: 900),
});
@override
@ -73,7 +71,8 @@ class _AnimatedFloatingNumberState extends State<_AnimatedFloatingNumber>
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: widget.duration);
_controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 900));
_fadeAnim = CurvedAnimation(parent: _controller, curve: Curves.easeOut);
_offsetAnim = Tween<Offset>(
begin: const Offset(0, 0),

View file

@ -1,4 +1,5 @@
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart';
import 'dart:math';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:flutter/material.dart';
@ -6,14 +7,14 @@ class NewWordOverlay extends StatefulWidget {
final Widget child;
final bool show;
final Color overlayColor;
final VoidCallback? onComplete;
final GlobalKey cardKey;
const NewWordOverlay({
super.key,
required this.child,
required this.show,
required this.overlayColor,
this.onComplete,
required this.cardKey,
});
@override
@ -22,125 +23,135 @@ class NewWordOverlay extends StatefulWidget {
class _NewWordOverlayState extends State<NewWordOverlay>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _xpScaleAnim;
late final Animation<double> _fadeAnim;
late final Animation<Alignment> _alignmentAnim;
late final Animation<Offset> _offsetAnim;
bool pointsBlast = false;
Widget xpSeedWidget = const SizedBox();
AnimationController? _controller;
Animation<double>? _xpScaleAnim;
Animation<double>? _fadeAnim;
Size size = const Size(0, 0);
Offset position = const Offset(0, 0);
OverlayEntry? _overlayEntry;
bool _animationStarted = false;
Widget? get svg => ConstructLevelEnum.seeds.icon();
@override
void initState() {
super.initState();
void _initAndStartAnimation() {
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_xpScaleAnim = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.bounceOut),
parent: _controller!,
curve: const Interval(0.0, 0.6, curve: Curves.easeInOut),
);
_fadeAnim = CurvedAnimation(
parent: _controller,
parent: _controller!,
curve: const Interval(0.7, 1.0, curve: Curves.easeOut),
);
_alignmentAnim = AlignmentTween(
begin: Alignment.center,
end: Alignment.topRight,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
// Offset animation: stays at Offset.zero, then moves up and right
_offsetAnim = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-0.1, -0.1),
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
_controller.addListener(() {
if (!pointsBlast && _controller.value >= 0.6) {
setState(() {
pointsBlast = true;
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
calculateSizeAndPosition();
_showFlyingWidget();
_controller?.forward();
});
}
xpSeedWidget = Container(
child: svg,
);
if (mounted) {
_controller.forward();
@override
void initState() {
super.initState();
if (widget.show) {
_initAndStartAnimation();
_animationStarted = true;
}
}
@override
void dispose() {
_controller.dispose();
_overlayEntry?.remove();
_controller?.dispose();
super.dispose();
}
void calculateSizeAndPosition() {
final RenderBox? box =
widget.cardKey.currentContext?.findRenderObject() as RenderBox?;
if (box != null) {
setState(() {
position = box.localToGlobal(const Offset(-455, 0));
size = box.size;
});
}
}
void _showFlyingWidget() {
if (_controller == null || _xpScaleAnim == null || _fadeAnim == null) {
return;
}
_overlayEntry = OverlayEntry(
builder: (context) => AnimatedBuilder(
animation: _controller!,
builder: (context, child) {
final scale = _xpScaleAnim!.value;
final fade = 1.0 - (_fadeAnim!.value);
// Calculate t for move to top left after 0.7
double t = 0.0;
if ((_controller!.value) >= 0.7) {
t = ((_controller!.value) - 0.7) / 0.3;
t = t.clamp(0.0, 1.0);
}
// Start position: center of card, End position: top left (0,0)
final startX = position.dx + size.width / 2 - (37 * scale);
final startY = position.dy + size.height / 2 + 20 - (37 * scale);
const endX = 0.0;
const endY = 0.0;
final currentX = startX * (1 - t) + endX * t;
final currentY = startY * (1 - t) + endY * t;
return Positioned(
left: currentX,
top: currentY,
child: Opacity(
opacity: fade,
child: Transform.rotate(
angle: scale * 2 * pi,
child: SizedBox(
width: 75 * scale,
height: 75 * scale,
child: svg ?? const SizedBox(),
),
),
),
);
},
),
);
Overlay.of(context).insert(_overlayEntry!);
_controller?.addStatusListener((status) {
if (status == AnimationStatus.completed) {
_overlayEntry?.remove();
_overlayEntry = null;
}
});
}
@override
Widget build(BuildContext context) {
if (!widget.show) return widget.child;
if (!widget.show && !_animationStarted) return widget.child;
return Stack(
children: [
widget.child,
Positioned(
left: 5,
right: 5,
top: 5,
top: 50,
bottom: 5,
child: Stack(
children: [
FadeTransition(
opacity: ReverseAnimation(_fadeAnim),
child: Container(
color: widget.overlayColor,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Align(
alignment: _alignmentAnim.value,
child: FractionalTranslation(
translation: _offsetAnim.value,
child: ScaleTransition(
scale: _xpScaleAnim,
child: Transform.scale(
scale: 2 * (.8 - _fadeAnim.value),
child: xpSeedWidget,
),
),
),
);
},
),
),
),
pointsBlast
? const Align(
alignment: Alignment.bottomCenter,
child: PointsGainedAnimation(
points: 10,
targetID: "",
),
)
: const SizedBox.shrink(),
],
child: FadeTransition(
opacity: ReverseAnimation(_fadeAnim ?? kAlwaysCompleteAnimation),
child: Container(
color: widget.overlayColor,
),
),
),
],
);
}
}
const kAlwaysCompleteAnimation = AlwaysStoppedAnimation<double>(1.0);

View file

@ -43,8 +43,10 @@ class WordZoomWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final GlobalKey cardKey = GlobalKey();
final overlayColor = Theme.of(context).scaffoldBackgroundColor;
final Widget card = Container(
key: cardKey,
padding: const EdgeInsets.all(12.0),
constraints: const BoxConstraints(
minHeight: AppConfig.toolbarMinHeight - 8,
@ -244,10 +246,11 @@ class WordZoomWidget extends StatelessWidget {
),
),
);
// Only wrap in NewWordOverlay if wordIsNew is true
return NewWordOverlay(
show: wordIsNew,
overlayColor: overlayColor,
cardKey: cardKey,
child: card,
);
}