mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
feat(ui): add Material Expressive spring motion
This commit is contained in:
1 parent
e049604b24
commit
5e6afc7a32
10 files changed
+351
-36
No files matched your search
@@ -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<HomeTab>
|
||||
!hasHomeFeedExtension &&
|
||||
!hasExploreContent &&
|
||||
!hasResults;
|
||||
final sectionMotion = AppMotion.spatial(
|
||||
context,
|
||||
const AppMotionSpec(Duration(milliseconds: 250), Curves.easeOut),
|
||||
);
|
||||
|
||||
ref.listen<String>(settingsProvider.select((s) => s.defaultSearchTab), (
|
||||
previous,
|
||||
@@ -936,8 +941,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
|
||||
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<HomeTab>
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
duration: sectionMotion.duration,
|
||||
curve: sectionMotion.curve,
|
||||
child:
|
||||
(!_showsHome ||
|
||||
hasResults ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<double>(
|
||||
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<double>(
|
||||
@@ -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(
|
||||
|
||||
@@ -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<T?> showAppDialog<T>({
|
||||
),
|
||||
);
|
||||
}
|
||||
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<T>(
|
||||
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<double>(
|
||||
begin: 0.85,
|
||||
end: 1,
|
||||
).chain(CurveTween(curve: scale.curve)),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
builder: builder,
|
||||
|
||||
@@ -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<T?> showAppModalBottomSheet<T>({
|
||||
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<T>(
|
||||
context: context,
|
||||
sheetAnimationStyle: spring == null
|
||||
? null
|
||||
: AnimationStyle(
|
||||
duration: spring.duration,
|
||||
curve: spring.curve,
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
isScrollControlled: isScrollControlled,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
|
||||
@@ -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<double>(
|
||||
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,
|
||||
|
||||
@@ -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<double>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<AnimatedSelectionBottomBar>
|
||||
@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<Offset>(
|
||||
begin: const Offset(0, 0.08),
|
||||
end: Offset.zero,
|
||||
).animate(curve);
|
||||
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
|
||||
).animate(CurvedAnimation(parent: _controller, curve: slide.curve));
|
||||
_fadeAnimation = Tween<double>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<SettingsSearchTarget> {
|
||||
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,
|
||||
|
||||
@@ -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<AppMotionSpec> 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<SpringCurve>(),
|
||||
);
|
||||
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));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user