diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 44437585..9a05f879 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; import 'package:spotiflac_android/widgets/app_shape.dart'; @@ -875,6 +876,10 @@ class _HomeTabState extends ConsumerState !hasHomeFeedExtension && !hasExploreContent && !hasResults; + final sectionMotion = AppMotion.spatial( + context, + const AppMotionSpec(Duration(milliseconds: 250), Curves.easeOut), + ); ref.listen(settingsProvider.select((s) => s.defaultSearchTab), ( previous, @@ -936,8 +941,8 @@ class _HomeTabState extends ConsumerState SliverToBoxAdapter( child: AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, + duration: sectionMotion.duration, + curve: sectionMotion.curve, child: (hasResults || showExplore || !_showsHome) ? const SizedBox.shrink() : _buildHomeIntro( @@ -1010,8 +1015,8 @@ class _HomeTabState extends ConsumerState SliverToBoxAdapter( child: AnimatedSize( - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, + duration: sectionMotion.duration, + curve: sectionMotion.curve, child: (!_showsHome || hasResults || diff --git a/lib/theme/app_motion.dart b/lib/theme/app_motion.dart new file mode 100644 index 00000000..02308284 --- /dev/null +++ b/lib/theme/app_motion.dart @@ -0,0 +1,90 @@ +import 'package:flutter/physics.dart'; +import 'package:flutter/widgets.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; + +/// A duration and curve pair for implicit animations. +@immutable +class AppMotionSpec { + const AppMotionSpec(this.duration, this.curve); + + final Duration duration; + final Curve curve; +} + +/// A [Curve] that follows an [M3ESpring] released from rest at 0 toward 1. +/// +/// [duration] is the spring's settling time, so implicit animations driven by +/// this curve end exactly when the physical motion does. +class SpringCurve extends Curve { + SpringCurve(M3ESpring spring) + : this._(SpringSimulation(spring.toDescription(), 0, 1, 0)); + + SpringCurve._(this._simulation) : duration = _settleTime(_simulation); + + final SpringSimulation _simulation; + final Duration duration; + + static Duration _settleTime(SpringSimulation simulation) { + const step = 1 / 1000; + var settledSince = 0.0; + for (var t = 0.0; t < 2; t += step) { + final settled = + (simulation.x(t) - 1).abs() < 0.002 && simulation.dx(t).abs() < 0.02; + if (!settled) { + settledSince = t + step; + } else if (t - settledSince >= 0.05) { + break; + } + } + return Duration(microseconds: (settledSince * 1e6).round()); + } + + @override + double transformInternal(double t) => + _simulation.x(t * duration.inMicroseconds / 1e6); +} + +/// Material 3 Expressive motion for the Material theme. +/// +/// Spatial springs (position, size, scale) overshoot slightly; effects +/// springs (opacity, color) settle without overshoot. Mornye and reduced +/// motion keep each call site's original [AppMotionSpec]. +abstract final class AppMotion { + static final _spatialFast = SpringCurve(M3EMotion.expressiveSpatialFast); + static final _spatialDefault = SpringCurve( + M3EMotion.expressiveSpatialDefault, + ); + static final _spatialSlow = SpringCurve(M3EMotion.expressiveSpatialSlow); + static final _effectsFast = SpringCurve(M3EMotion.effectsFast); + static final _effectsDefault = SpringCurve(M3EMotion.effectsDefault); + + static AppMotionSpec _spec(SpringCurve curve) => + AppMotionSpec(curve.duration, curve); + + /// Fast spatial spring for small components: chips, checks, buttons. + static AppMotionSpec spatialFast( + BuildContext context, + AppMotionSpec fallback, + ) => materialExpressiveEnabled(context) ? _spec(_spatialFast) : fallback; + + /// Default spatial spring for list items, panels and content changes. + static AppMotionSpec spatial(BuildContext context, AppMotionSpec fallback) => + materialExpressiveEnabled(context) ? _spec(_spatialDefault) : fallback; + + /// Slow spatial spring for full-screen surfaces such as the player. + static AppMotionSpec spatialSlow( + BuildContext context, + AppMotionSpec fallback, + ) => materialExpressiveEnabled(context) ? _spec(_spatialSlow) : fallback; + + /// Fast effects spring for opacity and color. + static AppMotionSpec effectsFast( + BuildContext context, + AppMotionSpec fallback, + ) => materialExpressiveEnabled(context) ? _spec(_effectsFast) : fallback; + + /// Default effects spring for opacity and color. + static AppMotionSpec effects(BuildContext context, AppMotionSpec fallback) => + materialExpressiveEnabled(context) ? _spec(_effectsDefault) : fallback; +} diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index c4124f4c..f30d6183 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; /// Wraps a child in a staggered fade-in + slide-up animation. @@ -33,6 +35,40 @@ class StaggeredListItem extends StatelessWidget { } final cappedIndex = index.clamp(0, _maxAnimatedItems - 1); final delay = staggerDelay * cappedIndex; + if (materialExpressiveEnabled(context)) { + // Expressive: each item springs up after its own delay and may + // overshoot slightly; opacity stays within range. + final motion = AppMotion.spatial( + context, + AppMotionSpec(duration, Curves.easeOutCubic), + ); + final total = motion.duration + delay; + final delayFraction = total.inMicroseconds > 0 + ? delay.inMicroseconds / total.inMicroseconds + : 0.0; + return TweenAnimationBuilder( + key: ValueKey('stagger_$index'), + tween: Tween(begin: 0.0, end: 1.0), + duration: total, + builder: (context, value, child) { + final elapsed = value <= delayFraction + ? 0.0 + : ((value - delayFraction) / (1.0 - delayFraction)).clamp( + 0.0, + 1.0, + ); + final progress = motion.curve.transform(elapsed); + return Opacity( + opacity: progress.clamp(0.0, 1.0), + child: Transform.translate( + offset: Offset(0, 16 * (1 - progress)), + child: child, + ), + ); + }, + child: child, + ); + } final totalDuration = duration + delay; return TweenAnimationBuilder( @@ -1037,12 +1073,21 @@ class AnimatedSelectionCheckbox extends StatelessWidget { @override Widget build(BuildContext context) { + final scale = AppMotion.spatialFast( + context, + const AppMotionSpec(Duration(milliseconds: 200), Curves.easeOutBack), + ); + final fill = AppMotion.effectsFast( + context, + const AppMotionSpec(Duration(milliseconds: 200), Curves.linear), + ); return AnimatedScale( scale: visible ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutBack, + duration: scale.duration, + curve: scale.curve, child: AnimatedContainer( - duration: const Duration(milliseconds: 200), + duration: fill.duration, + curve: fill.curve, width: size, height: size, decoration: BoxDecoration( diff --git a/lib/widgets/app_alert_dialog.dart b/lib/widgets/app_alert_dialog.dart index 4a9ef7f9..568ca85e 100644 --- a/lib/widgets/app_alert_dialog.dart +++ b/lib/widgets/app_alert_dialog.dart @@ -1,6 +1,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; @@ -24,6 +26,37 @@ Future showAppDialog({ ), ); } + if (materialExpressiveEnabled(context)) { + // Expressive: the dialog scales in on a spatial spring while an effects + // spring fades it, keeping opacity within range. + const fallback = AppMotionSpec(Duration.zero, Curves.linear); + final scale = AppMotion.spatial(context, fallback); + final fade = AppMotion.effectsFast(context, fallback); + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + final themes = InheritedTheme.capture(from: context, to: navigator.context); + return showGeneralDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: DialogTheme.of(context).barrierColor ?? Colors.black54, + useRootNavigator: useRootNavigator, + transitionDuration: scale.duration, + pageBuilder: (dialogContext, _, _) => + themes.wrap(SafeArea(child: Builder(builder: builder))), + transitionBuilder: (context, animation, _, child) => FadeTransition( + opacity: animation.drive(CurveTween(curve: fade.curve)), + child: ScaleTransition( + scale: animation.drive( + Tween( + begin: 0.85, + end: 1, + ).chain(CurveTween(curve: scale.curve)), + ), + child: child, + ), + ), + ); + } return showDialog( context: context, builder: builder, diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index 65ce927f..5e04afca 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoButton, CupertinoDynamicColor, kCupertinoModalBarrierColor; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -182,8 +184,23 @@ Future showAppModalBottomSheet({ Color? backgroundColor, }) { final mornye = context.isMornye; + // Expressive sheets rise on a critically damped spring, so they never + // overshoot their own height and reveal the barrier underneath. + final spring = materialExpressiveEnabled(context) + ? AppMotion.spatialSlow( + context, + const AppMotionSpec(Duration.zero, Curves.linear), + ) + : null; return showModalBottomSheet( context: context, + sheetAnimationStyle: spring == null + ? null + : AnimationStyle( + duration: spring.duration, + curve: spring.curve, + reverseDuration: const Duration(milliseconds: 200), + ), isScrollControlled: isScrollControlled, isDismissible: isDismissible, enableDrag: enableDrag, diff --git a/lib/widgets/expressive_playback_transport.dart b/lib/widgets/expressive_playback_transport.dart index 1ead9724..ef1e2b48 100644 --- a/lib/widgets/expressive_playback_transport.dart +++ b/lib/widgets/expressive_playback_transport.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; @@ -54,6 +55,7 @@ class _ExpressivePlaybackTransportState static const _sideActionWidth = 48.0; static const _minTarget = 48.0; static const _squishRatio = 0.15; + static final _pressSpring = SpringCurve(M3EMotion.expressiveSpatialPress); int? _pressed; @@ -178,8 +180,8 @@ class _ExpressivePlaybackTransportState // One curve for every slot keeps the summed width constant mid-flight. child: TweenAnimationBuilder( tween: Tween(end: width), - duration: const Duration(milliseconds: 350), - curve: Curves.easeOutBack, + duration: _pressSpring.duration, + curve: _pressSpring, builder: (context, width, child) => SizedBox(width: math.max(0, width), height: height, child: child), child: child, diff --git a/lib/widgets/material_player_layout.dart b/lib/widgets/material_player_layout.dart index f7f5454b..cb19168b 100644 --- a/lib/widgets/material_player_layout.dart +++ b/lib/widgets/material_player_layout.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/controllers/player_metadata_controller.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/routes/now_playing_route.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; +import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; @@ -47,17 +49,23 @@ class MaterialPlayerLayout extends StatelessWidget { final resting = !ref.watch(playbackPlayingProvider) && !ref.watch(playbackLoadingProvider); - final duration = MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 500); + final motion = AppMotion.spatialSlow( + context, + AppMotionSpec( + MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 500), + Curves.easeOutBack, + ), + ); return AnimatedScale( scale: resting ? 0.9 : 1, - duration: duration, - curve: Curves.easeOutBack, + duration: motion.duration, + curve: motion.curve, child: TweenAnimationBuilder( tween: Tween(end: resting ? 40 : 20), - duration: duration, - curve: Curves.easeOutCubic, + duration: motion.duration, + curve: motion.curve, builder: (context, radius, artwork) => ClipRRect( borderRadius: BorderRadius.circular(radius), child: artwork, @@ -175,20 +183,28 @@ class MaterialPlayerLayout extends StatelessWidget { BuildContext context, { bool compact = false, }) { + final expressive = materialExpressiveEnabled(context); + const fallback = AppMotionSpec(Duration(milliseconds: 350), Curves.linear); + final slide = AppMotion.spatial(context, fallback); + final fade = AppMotion.effects(context, fallback); return [ Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, + duration: slide.duration, + // Expressive: a spatial spring moves the title while an effects + // spring fades it, so opacity never overshoots. + switchInCurve: expressive ? Curves.linear : Curves.easeOutCubic, + switchOutCurve: expressive ? Curves.linear : Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, + opacity: expressive + ? animation.drive(CurveTween(curve: fade.curve)) + : animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.15), end: Offset.zero, - ).animate(animation), + ).chain(CurveTween(curve: slide.curve)).animate(animation), child: child, ), ), diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 48cc8c39..e9a9ce31 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/scheduler.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -183,19 +184,29 @@ class _AnimatedSelectionBottomBarState extends State @override void initState() { super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 240), - ); - final curve = CurvedAnimation( - parent: _controller, - curve: Curves.easeOutCubic, + _controller = AnimationController(vsync: this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controller.duration != null) return; + // Expressive: the bar springs into place while fading without overshoot. + const fallback = AppMotionSpec( + Duration(milliseconds: 240), + Curves.easeOutCubic, ); + final slide = AppMotion.spatial(context, fallback); + final fade = AppMotion.effects(context, fallback); + _controller.duration = slide.duration; _slideAnimation = Tween( begin: const Offset(0, 0.08), end: Offset.zero, - ).animate(curve); - _fadeAnimation = Tween(begin: 0, end: 1).animate(curve); + ).animate(CurvedAnimation(parent: _controller, curve: slide.curve)); + _fadeAnimation = Tween( + begin: 0, + end: 1, + ).animate(CurvedAnimation(parent: _controller, curve: fade.curve)); _controller.forward(); } @@ -493,12 +504,19 @@ class _SelectionPanelDragState extends State<_SelectionPanelDrag> void _setExpanded(bool expanded) { setState(() => _expanded = expanded); if (!expanded && _scroll.hasClients) _scroll.jumpTo(0); + final motion = AppMotion.spatialSlow( + context, + AppMotionSpec( + MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + Curves.easeOutCubic, + ), + ); _expansion.animateTo( expanded ? 1 : 0, - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, + duration: motion.duration, + curve: motion.curve, ); _animateToRest(0); } diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index bfaa37cf..39e7455a 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; @@ -173,12 +174,16 @@ class _SettingsSearchTargetState extends State { if (!_handled) return widget.child; final colorScheme = Theme.of(context).colorScheme; + final motion = AppMotion.effects( + context, + AppMotionSpec(context.tokens.motionMedium, Curves.easeOutCubic), + ); return Semantics( selected: _highlighted, child: AnimatedContainer( key: ValueKey('settings-highlight:${widget.label}'), - duration: context.tokens.motionMedium, - curve: Curves.easeOutCubic, + duration: motion.duration, + curve: motion.curve, color: _highlighted ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, diff --git a/test/app_motion_test.dart b/test/app_motion_test.dart new file mode 100644 index 00000000..93f0399c --- /dev/null +++ b/test/app_motion_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_3_expressive/material_3_expressive.dart'; +import 'package:spotiflac_android/theme/app_motion.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +void main() { + test('spring curves start at rest and settle within their duration', () { + for (final spring in [ + M3EMotion.expressiveSpatialFast, + M3EMotion.expressiveSpatialDefault, + M3EMotion.expressiveSpatialSlow, + M3EMotion.effectsFast, + M3EMotion.effectsDefault, + ]) { + final curve = SpringCurve(spring); + expect(curve.transform(0), 0); + expect(curve.transform(1), 1); + expect(curve.transform(0.98), closeTo(1, 0.01)); + expect(curve.duration.inMilliseconds, inInclusiveRange(100, 1200)); + } + }); + + test('spatial springs overshoot while effects springs do not', () { + double peak(SpringCurve curve) => [ + for (var t = 0.0; t <= 1; t += 0.01) curve.transform(t), + ].reduce((a, b) => a > b ? a : b); + + expect( + peak(SpringCurve(M3EMotion.expressiveSpatialDefault)), + greaterThan(1), + ); + expect(peak(SpringCurve(M3EMotion.effectsDefault)), lessThanOrEqualTo(1)); + }); + + Future resolve( + WidgetTester tester, { + required ThemeData theme, + bool reduceMotion = false, + }) async { + late AppMotionSpec spec; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Builder( + builder: (context) { + spec = AppMotion.spatial( + context, + const AppMotionSpec( + Duration(milliseconds: 250), + Curves.easeOutCubic, + ), + ); + return const SizedBox(); + }, + ), + ), + ), + ); + // MaterialApp animates theme changes between pumps. + await tester.pumpAndSettle(); + return spec; + } + + testWidgets( + 'Material uses springs; Mornye and reduced motion keep fallback', + (tester) async { + expect( + (await resolve(tester, theme: AppTheme.light())).curve, + isA(), + ); + for (final spec in [ + await resolve(tester, theme: MornyeTheme.build(Brightness.light)), + await resolve(tester, theme: AppTheme.light(), reduceMotion: true), + ]) { + expect(spec.curve, Curves.easeOutCubic); + expect(spec.duration, const Duration(milliseconds: 250)); + } + }, + ); +}