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 createState() => _M3FadeSlideInState(); } class _M3FadeSlideInState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _opacity; late final Animation _slide; @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: widget.duration); _opacity = CurvedAnimation( parent: _controller, curve: M3Motion.emphasizedEnter, ); _slide = Tween(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 extends PageRouteBuilder { 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 animation, Animation 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 extends CustomTransitionPage { 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 animation, Animation 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 extends CustomTransitionPage { 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 animation, Animation 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 animation; final Animation 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( 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 createState() => _M3FabState(); } class _M3FabState extends State with SingleTickerProviderStateMixin { late final AnimationController _scaleCtrl; late final Animation _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 createState() => _M3ExpandedFabState(); } class _M3ExpandedFabState extends State with SingleTickerProviderStateMixin { late final AnimationController _entranceCtrl; late final Animation _scale; late final Animation _slide; @override void initState() { super.initState(); _entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long); _scale = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring); _slide = Tween(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 m3ShowDialog({ required BuildContext context, required WidgetBuilder builder, bool barrierDismissible = true, }) { return showGeneralDialog( 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(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 m3ShowBottomSheet({ required BuildContext context, required WidgetBuilder builder, bool showDragHandle = true, bool isScrollControlled = false, }) { return showModalBottomSheet( context: context, showDragHandle: showDragHandle, isScrollControlled: isScrollControlled, transitionAnimationController: AnimationController( vsync: Navigator.of(context), duration: M3Motion.standard, reverseDuration: M3Motion.short, ), builder: builder, ); }