From 607f9c440b339c9952977499c22e370f1f2f7cbd Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:50:52 +0700 Subject: [PATCH] feat(player): animate circular feedback on transport buttons --- lib/widgets/mornye_playback_button.dart | 47 +++++++++++++++++++++++-- test/mornye_playback_button_test.dart | 47 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/lib/widgets/mornye_playback_button.dart b/lib/widgets/mornye_playback_button.dart index 33b86b63..dd2a9d7c 100644 --- a/lib/widgets/mornye_playback_button.dart +++ b/lib/widgets/mornye_playback_button.dart @@ -27,8 +27,13 @@ class MornyePlaybackButton extends StatefulWidget { } class _MornyePlaybackButtonState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { final _states = WidgetStatesController(); + late final _press = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 100), + reverseDuration: const Duration(milliseconds: 320), + ); late final _skip = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), @@ -38,9 +43,32 @@ class _MornyePlaybackButtonState extends State widget.icon == CupertinoIcons.forward_fill || widget.icon == CupertinoIcons.backward_fill; + @override + void initState() { + super.initState(); + _states.addListener(_updatePress); + _press.addStatusListener((status) { + if (status == AnimationStatus.completed && + !_states.value.contains(WidgetState.pressed)) { + _press.reverse(); + } + }); + } + + void _updatePress() { + if (_states.value.contains(WidgetState.pressed)) { + _press.forward(); + } else if (_press.isCompleted) { + _press.reverse(); + } + // A quick tap still finishes its fade-in before fading back out. + } + @override void dispose() { + _states.removeListener(_updatePress); _states.dispose(); + _press.dispose(); _skip.dispose(); super.dispose(); } @@ -83,13 +111,14 @@ class _MornyePlaybackButtonState extends State final reduceMotion = MediaQuery.disableAnimationsOf(context); final motion = Duration(milliseconds: reduceMotion ? 120 : 180); final enabled = widget.onPressed != null && !widget.loading; - return IconButton( + final button = IconButton( statesController: _states, tooltip: widget.tooltip, color: widget.color, iconSize: widget.iconSize, padding: widget.padding, style: ButtonStyle( + shape: const WidgetStatePropertyAll(CircleBorder()), splashFactory: NoSplash.splashFactory, overlayColor: WidgetStateProperty.resolveWith((states) { return states.contains(WidgetState.focused) || @@ -100,6 +129,7 @@ class _MornyePlaybackButtonState extends State ), onPressed: enabled ? () { + _press.forward(); if (_isSkip && !reduceMotion) _skip.forward(from: 0); widget.onPressed!(); } @@ -139,5 +169,18 @@ class _MornyePlaybackButtonState extends State }, ), ); + return AnimatedBuilder( + animation: _press, + child: button, + builder: (context, child) => DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.color.withValues( + alpha: 0.14 * Curves.easeOutCubic.transform(_press.value), + ), + ), + child: child, + ), + ); } } diff --git a/test/mornye_playback_button_test.dart b/test/mornye_playback_button_test.dart index c2df633e..bcf79f4e 100644 --- a/test/mornye_playback_button_test.dart +++ b/test/mornye_playback_button_test.dart @@ -27,6 +27,18 @@ void main() { ), ); + double feedbackOpacity(WidgetTester tester) { + final feedback = tester.widget( + find + .ancestor( + of: find.byType(IconButton), + matching: find.byType(DecoratedBox), + ) + .first, + ); + return (feedback.decoration as BoxDecoration).color!.a; + } + testWidgets('press feedback keeps the tap target fixed and cancels safely', ( tester, ) async { @@ -36,15 +48,18 @@ void main() { final icon = find.byIcon(CupertinoIcons.play_fill); final bounds = tester.getRect(button); final iconBounds = tester.getRect(icon); + expect(feedbackOpacity(tester), 0); final press = await tester.startGesture(bounds.center); await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 200)); expect(tester.getRect(icon).width, lessThan(iconBounds.width)); expect(tester.getRect(button), bounds); + expect(feedbackOpacity(tester), closeTo(0.14, 0.001)); await press.up(); await tester.pumpAndSettle(); expect(calls, 1); expect(tester.getRect(icon), iconBounds); + expect(feedbackOpacity(tester), 0); final cancelled = await tester.startGesture(bounds.center); await tester.pump(const Duration(milliseconds: 100)); @@ -52,8 +67,40 @@ void main() { await tester.pumpAndSettle(); expect(calls, 1); expect(tester.getRect(icon), iconBounds); + expect(feedbackOpacity(tester), 0); }); + for (final icon in [ + CupertinoIcons.play_fill, + CupertinoIcons.pause_fill, + CupertinoIcons.forward_fill, + CupertinoIcons.backward_fill, + ]) { + testWidgets('quick tap on $icon fades a circular highlight in and out', ( + tester, + ) async { + var calls = 0; + await pumpButton(tester, icon: icon, onPressed: () => calls++); + final button = find.byType(IconButton); + final bounds = tester.getRect(button); + await tester.tap(button); + expect(calls, 1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final entering = feedbackOpacity(tester); + expect(entering, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 60)); + final peak = feedbackOpacity(tester); + expect(peak, greaterThan(entering)); + await tester.pump(const Duration(milliseconds: 220)); + expect(feedbackOpacity(tester), lessThan(peak)); + expect(tester.getRect(button), bounds); + await tester.pumpAndSettle(); + expect(feedbackOpacity(tester), 0); + expect(calls, 1); + }); + } + for (final icon in [ CupertinoIcons.forward_fill, CupertinoIcons.backward_fill,