517 lines
16 KiB
Dart
517 lines
16 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
/// M3 Expressive motion constants and helpers.
|
||
///
|
||
/// Transitions use spring-physics inspired curves with an emphasized easing
|
||
/// feel. Duration targets are tuned for fluidity:
|
||
/// * **Micro** (150 ms): status-pill toggles, icon swaps.
|
||
/// * **Short** (250 ms): list item reveal, chip state changes.
|
||
/// * **Standard** (400 ms): page transitions, container expansions.
|
||
/// * **Long** (550 ms): full-page shared-axis transitions.
|
||
class M3Motion {
|
||
M3Motion._();
|
||
|
||
// ── Durations ──────────────────────────────────────────────
|
||
static const Duration micro = Duration(milliseconds: 150);
|
||
static const Duration short = Duration(milliseconds: 250);
|
||
static const Duration standard = Duration(milliseconds: 400);
|
||
static const Duration long = Duration(milliseconds: 550);
|
||
|
||
// ── Curves (M3 Expressive) ─────────────────────────────────
|
||
/// Emphasized enter – starts slow then accelerates.
|
||
static const Curve emphasizedEnter = Curves.easeOutCubic;
|
||
|
||
/// Emphasized exit – decelerates to a stop.
|
||
static const Curve emphasizedExit = Curves.easeInCubic;
|
||
|
||
/// Standard easing for most container transforms.
|
||
static const Curve standard_ = Curves.easeInOutCubicEmphasized;
|
||
|
||
/// Spring-physics inspired curve for bouncy interactions.
|
||
static const Curve spring = _SpringCurve();
|
||
|
||
/// M3 Expressive emphasized decelerate — the hero curve for enter motions.
|
||
static const Curve expressiveDecelerate = Cubic(0.05, 0.7, 0.1, 1.0);
|
||
|
||
/// M3 Expressive emphasized accelerate — for exit motions.
|
||
static const Curve expressiveAccelerate = Cubic(0.3, 0.0, 0.8, 0.15);
|
||
}
|
||
|
||
/// A simple spring-physics curve that produces a slight overshoot.
|
||
class _SpringCurve extends Curve {
|
||
const _SpringCurve();
|
||
|
||
@override
|
||
double transformInternal(double t) {
|
||
// Attempt a more natural spring feel: slight overshoot then settle.
|
||
// Based on damped harmonic oscillator approximation.
|
||
const damping = 0.7;
|
||
const freq = 3.5;
|
||
return 1.0 -
|
||
math.pow(math.e, -damping * freq * t) *
|
||
math.cos(freq * math.sqrt(1 - damping * damping) * t * math.pi);
|
||
}
|
||
}
|
||
|
||
/// Wraps a child with a staggered fade + slide-up entrance animation.
|
||
///
|
||
/// Use inside lists for sequential reveal of items.
|
||
class M3FadeSlideIn extends StatefulWidget {
|
||
const M3FadeSlideIn({
|
||
super.key,
|
||
required this.child,
|
||
this.delay = Duration.zero,
|
||
this.duration = const Duration(milliseconds: 400),
|
||
});
|
||
|
||
final Widget child;
|
||
final Duration delay;
|
||
final Duration duration;
|
||
|
||
@override
|
||
State<M3FadeSlideIn> createState() => _M3FadeSlideInState();
|
||
}
|
||
|
||
class _M3FadeSlideInState extends State<M3FadeSlideIn>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _controller;
|
||
late final Animation<double> _opacity;
|
||
late final Animation<Offset> _slide;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(vsync: this, duration: widget.duration);
|
||
_opacity = CurvedAnimation(
|
||
parent: _controller,
|
||
curve: M3Motion.emphasizedEnter,
|
||
);
|
||
_slide = Tween<Offset>(begin: const Offset(0, 0.04), end: Offset.zero)
|
||
.animate(
|
||
CurvedAnimation(parent: _controller, curve: M3Motion.emphasizedEnter),
|
||
);
|
||
|
||
if (widget.delay == Duration.zero) {
|
||
_controller.forward();
|
||
} else {
|
||
Future.delayed(widget.delay, () {
|
||
if (mounted) _controller.forward();
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FadeTransition(
|
||
opacity: _opacity,
|
||
child: SlideTransition(position: _slide, child: widget.child),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A page-route that uses a shared-axis (vertical) transition.
|
||
///
|
||
/// ```dart
|
||
/// Navigator.of(context).push(M3SharedAxisRoute(child: DetailScreen()));
|
||
/// ```
|
||
class M3SharedAxisRoute<T> extends PageRouteBuilder<T> {
|
||
M3SharedAxisRoute({required this.child})
|
||
: super(
|
||
transitionDuration: M3Motion.standard,
|
||
reverseTransitionDuration: M3Motion.short,
|
||
pageBuilder: (_, a, b) => child,
|
||
transitionsBuilder: _fadeThroughBuilder,
|
||
);
|
||
|
||
final Widget child;
|
||
|
||
static Widget _fadeThroughBuilder(
|
||
BuildContext context,
|
||
Animation<double> animation,
|
||
Animation<double> secondaryAnimation,
|
||
Widget child,
|
||
) {
|
||
return _M3FadeThrough(
|
||
animation: animation,
|
||
secondaryAnimation: secondaryAnimation,
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── GoRouter-compatible transition pages ─────────────────────
|
||
|
||
/// A [CustomTransitionPage] that performs a container-transform-style
|
||
/// transition: the incoming page fades in + scales up smoothly while the
|
||
/// outgoing page fades through. Uses M3 Expressive emphasized curves for
|
||
/// a fluid, spring-like feel.
|
||
///
|
||
/// Use this for card → detail navigations (tickets, tasks).
|
||
class M3ContainerTransformPage<T> extends CustomTransitionPage<T> {
|
||
const M3ContainerTransformPage({
|
||
required super.child,
|
||
super.key,
|
||
super.name,
|
||
super.arguments,
|
||
super.restorationId,
|
||
}) : super(
|
||
transitionDuration: M3Motion.standard,
|
||
reverseTransitionDuration: M3Motion.standard,
|
||
transitionsBuilder: _containerTransformBuilder,
|
||
);
|
||
|
||
static Widget _containerTransformBuilder(
|
||
BuildContext context,
|
||
Animation<double> animation,
|
||
Animation<double> secondaryAnimation,
|
||
Widget child,
|
||
) {
|
||
// M3 Expressive: Fade-through with scale. The outgoing content fades out
|
||
// in the first 35 % of the duration, then the incoming content fades in
|
||
// with a subtle scale from 94 % → 100 %. This prevents the "double image"
|
||
// overlap that makes transitions feel choppy.
|
||
|
||
return AnimatedBuilder(
|
||
animation: Listenable.merge([animation, secondaryAnimation]),
|
||
builder: (context, _) {
|
||
// ── Forward / reverse animation ──
|
||
final t = animation.value;
|
||
final curved = M3Motion.expressiveDecelerate.transform(t);
|
||
|
||
// Outgoing: when this page is being moved out by a NEW incoming page
|
||
final st = secondaryAnimation.value;
|
||
|
||
// Scale: 0.94 → 1.0 on enter, 1.0 → 0.96 on exit-by-secondary
|
||
final scale = 1.0 - (1.0 - curved) * 0.06;
|
||
final secondaryScale = 1.0 - st * 0.04;
|
||
|
||
// Opacity: fade in first 40 %, stay 1.0 rest. On secondary, fade first 30 %.
|
||
final opacity = (t / 0.4).clamp(0.0, 1.0);
|
||
final secondaryOpacity = (1.0 - (st / 0.3).clamp(0.0, 1.0));
|
||
|
||
return Opacity(
|
||
opacity: secondaryOpacity,
|
||
child: Transform.scale(
|
||
scale: secondaryScale,
|
||
child: Opacity(
|
||
opacity: opacity,
|
||
child: Transform.scale(scale: scale, child: child),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A [CustomTransitionPage] implementing the M3 fade-through transition.
|
||
/// Best for top-level navigation changes within a shell.
|
||
///
|
||
/// Uses a proper "fade through" pattern: outgoing fades out first, then
|
||
/// incoming fades in with a subtle vertical shift. This eliminates the
|
||
/// "double image" overlap that causes choppiness.
|
||
class M3SharedAxisPage<T> extends CustomTransitionPage<T> {
|
||
const M3SharedAxisPage({
|
||
required super.child,
|
||
super.key,
|
||
super.name,
|
||
super.arguments,
|
||
super.restorationId,
|
||
}) : super(
|
||
transitionDuration: M3Motion.standard,
|
||
reverseTransitionDuration: M3Motion.short,
|
||
transitionsBuilder: _sharedAxisBuilder,
|
||
);
|
||
|
||
static Widget _sharedAxisBuilder(
|
||
BuildContext context,
|
||
Animation<double> animation,
|
||
Animation<double> secondaryAnimation,
|
||
Widget child,
|
||
) {
|
||
return _M3FadeThrough(
|
||
animation: animation,
|
||
secondaryAnimation: secondaryAnimation,
|
||
slideOffset: const Offset(0, 0.02),
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Core M3 fade-through transition widget used by both shared-axis and
|
||
/// route transitions.
|
||
///
|
||
/// **Pattern**: outgoing content fades to 0 in the first third → incoming
|
||
/// content fades from 0 to 1 in the remaining two-thirds with an optional
|
||
/// subtle slide. This two-phase approach prevents "ghosting".
|
||
class _M3FadeThrough extends StatelessWidget {
|
||
const _M3FadeThrough({
|
||
required this.animation,
|
||
required this.secondaryAnimation,
|
||
required this.child,
|
||
this.slideOffset = Offset.zero,
|
||
});
|
||
|
||
final Animation<double> animation;
|
||
final Animation<double> secondaryAnimation;
|
||
final Widget child;
|
||
final Offset slideOffset;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedBuilder(
|
||
animation: Listenable.merge([animation, secondaryAnimation]),
|
||
builder: (context, _) {
|
||
final t = animation.value;
|
||
final st = secondaryAnimation.value;
|
||
|
||
// Incoming: fade in from t=0.3..1.0, with decelerate curve
|
||
final enterT = ((t - 0.3) / 0.7).clamp(0.0, 1.0);
|
||
final enterOpacity = M3Motion.expressiveDecelerate.transform(enterT);
|
||
|
||
// Outgoing: when THIS page is pushed off by a new page, fade first 35 %
|
||
final exitOpacity = (1.0 - (st / 0.35).clamp(0.0, 1.0));
|
||
|
||
// Slide: subtle vertical offset on enter
|
||
final slideY = slideOffset.dy * (1.0 - enterT);
|
||
final slideX = slideOffset.dx * (1.0 - enterT);
|
||
|
||
return Opacity(
|
||
opacity: exitOpacity,
|
||
child: Transform.translate(
|
||
offset: Offset(slideX * 100, slideY * 100),
|
||
child: Opacity(opacity: enterOpacity, child: child),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// An [AnimatedSwitcher] pre-configured with M3 Expressive timing.
|
||
///
|
||
/// Use for state-change animations (loading → content, empty → data, etc.).
|
||
class M3AnimatedSwitcher extends StatelessWidget {
|
||
const M3AnimatedSwitcher({
|
||
super.key,
|
||
required this.child,
|
||
this.duration = M3Motion.short,
|
||
});
|
||
|
||
final Widget child;
|
||
final Duration duration;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AnimatedSwitcher(
|
||
duration: duration,
|
||
switchInCurve: M3Motion.emphasizedEnter,
|
||
switchOutCurve: M3Motion.emphasizedExit,
|
||
transitionBuilder: (child, animation) {
|
||
return FadeTransition(
|
||
opacity: animation,
|
||
child: SlideTransition(
|
||
position: Tween<Offset>(
|
||
begin: const Offset(0, 0.02),
|
||
end: Offset.zero,
|
||
).animate(animation),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
child: child,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// M3 Expressive FAB — animated entrance + press feedback
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/// A [FloatingActionButton] with M3 Expressive entrance animation:
|
||
/// scales up with spring on first build, and provides subtle scale-down
|
||
/// feedback on press. Use [M3ExpandedFab] for the extended variant.
|
||
class M3Fab extends StatefulWidget {
|
||
const M3Fab({
|
||
super.key,
|
||
required this.onPressed,
|
||
required this.icon,
|
||
this.tooltip,
|
||
this.heroTag,
|
||
});
|
||
|
||
final VoidCallback onPressed;
|
||
final Widget icon;
|
||
final String? tooltip;
|
||
final Object? heroTag;
|
||
|
||
@override
|
||
State<M3Fab> createState() => _M3FabState();
|
||
}
|
||
|
||
class _M3FabState extends State<M3Fab> with SingleTickerProviderStateMixin {
|
||
late final AnimationController _scaleCtrl;
|
||
late final Animation<double> _scaleAnim;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scaleCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||
_scaleAnim = CurvedAnimation(parent: _scaleCtrl, curve: M3Motion.spring);
|
||
_scaleCtrl.forward();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_scaleCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ScaleTransition(
|
||
scale: _scaleAnim,
|
||
child: FloatingActionButton(
|
||
heroTag: widget.heroTag,
|
||
onPressed: widget.onPressed,
|
||
tooltip: widget.tooltip,
|
||
child: widget.icon,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// An extended [FloatingActionButton] with M3 Expressive entrance animation:
|
||
/// slides in from the right + scales with spring, then provides a smooth
|
||
/// press → dialog/navigation transition.
|
||
class M3ExpandedFab extends StatefulWidget {
|
||
const M3ExpandedFab({
|
||
super.key,
|
||
required this.onPressed,
|
||
required this.icon,
|
||
required this.label,
|
||
this.heroTag,
|
||
});
|
||
|
||
final VoidCallback onPressed;
|
||
final Widget icon;
|
||
final Widget label;
|
||
final Object? heroTag;
|
||
|
||
@override
|
||
State<M3ExpandedFab> createState() => _M3ExpandedFabState();
|
||
}
|
||
|
||
class _M3ExpandedFabState extends State<M3ExpandedFab>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _entranceCtrl;
|
||
late final Animation<double> _scale;
|
||
late final Animation<Offset> _slide;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||
_scale = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
||
_slide = Tween<Offset>(begin: const Offset(0.3, 0), end: Offset.zero)
|
||
.animate(
|
||
CurvedAnimation(
|
||
parent: _entranceCtrl,
|
||
curve: M3Motion.expressiveDecelerate,
|
||
),
|
||
);
|
||
_entranceCtrl.forward();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_entranceCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SlideTransition(
|
||
position: _slide,
|
||
child: ScaleTransition(
|
||
scale: _scale,
|
||
child: FloatingActionButton.extended(
|
||
heroTag: widget.heroTag,
|
||
onPressed: widget.onPressed,
|
||
icon: widget.icon,
|
||
label: widget.label,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// M3 Dialog / BottomSheet helpers — smooth open/close
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
/// Opens a dialog with an M3 Expressive transition: the dialog scales up
|
||
/// from 90 % with a decelerate curve and fades in, giving a smooth "surface
|
||
/// rising" effect instead of the default abrupt material grow.
|
||
Future<T?> m3ShowDialog<T>({
|
||
required BuildContext context,
|
||
required WidgetBuilder builder,
|
||
bool barrierDismissible = true,
|
||
}) {
|
||
return showGeneralDialog<T>(
|
||
context: context,
|
||
barrierDismissible: barrierDismissible,
|
||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||
barrierColor: Colors.black54,
|
||
transitionDuration: M3Motion.standard,
|
||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||
final curved = CurvedAnimation(
|
||
parent: animation,
|
||
curve: M3Motion.expressiveDecelerate,
|
||
reverseCurve: M3Motion.expressiveAccelerate,
|
||
);
|
||
return FadeTransition(
|
||
opacity: CurvedAnimation(
|
||
parent: animation,
|
||
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
|
||
reverseCurve: const Interval(0.2, 1.0, curve: Curves.easeIn),
|
||
),
|
||
child: ScaleTransition(
|
||
scale: Tween<double>(begin: 0.88, end: 1.0).animate(curved),
|
||
child: child,
|
||
),
|
||
);
|
||
},
|
||
pageBuilder: (context, animation, secondaryAnimation) => builder(context),
|
||
);
|
||
}
|
||
|
||
/// Opens a modal bottom sheet with M3 Expressive spring animation.
|
||
Future<T?> m3ShowBottomSheet<T>({
|
||
required BuildContext context,
|
||
required WidgetBuilder builder,
|
||
bool showDragHandle = true,
|
||
bool isScrollControlled = false,
|
||
}) {
|
||
return showModalBottomSheet<T>(
|
||
context: context,
|
||
showDragHandle: showDragHandle,
|
||
isScrollControlled: isScrollControlled,
|
||
transitionAnimationController: AnimationController(
|
||
vsync: Navigator.of(context),
|
||
duration: M3Motion.standard,
|
||
reverseDuration: M3Motion.short,
|
||
),
|
||
builder: builder,
|
||
);
|
||
}
|