import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; // ── Reduced-motion helper ───────────────────────────────────────────────────── /// Returns true when the OS/user has requested reduced motion. /// /// Always query this in the [build] method — never in [initState] — because /// the value is read from [MediaQuery] which requires a valid [BuildContext]. bool m3ReducedMotion(BuildContext context) => MediaQuery.of(context).disableAnimations; /// 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) { // Skip animation entirely when the OS requests reduced motion. if (m3ReducedMotion(context)) return widget.child; 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, ); } // ═══════════════════════════════════════════════════════════════ // M3ShimmerBox — animated loading skeleton shimmer // ═══════════════════════════════════════════════════════════════ /// An animated shimmer placeholder used during loading states. /// /// The highlight sweeps from left to right at 1.2-second intervals, /// matching the "loading skeleton" pattern from M3 guidelines. /// Automatically falls back to a static surface when the OS requests /// reduced motion. /// /// ```dart /// M3ShimmerBox(width: 120, height: 14, borderRadius: BorderRadius.circular(4)) /// ``` class M3ShimmerBox extends StatefulWidget { const M3ShimmerBox({ super.key, this.width, this.height, this.borderRadius, this.child, }); final double? width; final double? height; /// Defaults to `BorderRadius.circular(6)` when null. final BorderRadius? borderRadius; /// Optional child rendered on top of the shimmer surface. final Widget? child; @override State createState() => _M3ShimmerBoxState(); } class _M3ShimmerBoxState extends State with SingleTickerProviderStateMixin { late final AnimationController _ctrl; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(); } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final radius = widget.borderRadius ?? BorderRadius.circular(6); final baseColor = cs.surfaceContainerHighest; final highlightColor = cs.surfaceContainerHigh; if (m3ReducedMotion(context)) { return Container( width: widget.width, height: widget.height, decoration: BoxDecoration(color: baseColor, borderRadius: radius), child: widget.child, ); } return AnimatedBuilder( animation: _ctrl, builder: (context, child) { // Shimmer highlight sweeps from -0.5 → 1.5 across the widget width. final t = _ctrl.value; final dx = -0.5 + t * 2.0; return Container( width: widget.width, height: widget.height, decoration: BoxDecoration( borderRadius: radius, gradient: LinearGradient( begin: Alignment(dx - 0.5, 0), end: Alignment(dx + 0.5, 0), colors: [baseColor, highlightColor, baseColor], ), ), child: child, ); }, child: widget.child, ); } } // ═══════════════════════════════════════════════════════════════ // M3PressScale — press-to-scale micro-interaction // ═══════════════════════════════════════════════════════════════ /// Wraps any widget with a subtle scale-down animation on press, giving /// tactile visual feedback for tappable surfaces (cards, list tiles, etc.). /// /// Uses [M3Motion.micro] duration with [M3Motion.emphasizedEnter] curve /// so the press response feels instant but controlled. /// /// ```dart /// M3PressScale( /// onTap: () => context.go('/detail'), /// child: Card(child: ...), /// ) /// ``` class M3PressScale extends StatefulWidget { const M3PressScale({ super.key, required this.child, this.onTap, this.onLongPress, this.scale = 0.97, }); final Widget child; final VoidCallback? onTap; final VoidCallback? onLongPress; /// The scale factor applied during a press. Defaults to `0.97`. final double scale; @override State createState() => _M3PressScaleState(); } class _M3PressScaleState extends State with SingleTickerProviderStateMixin { late final AnimationController _ctrl; late final Animation _scaleAnim; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: M3Motion.micro); _scaleAnim = Tween(begin: 1.0, end: widget.scale).animate( CurvedAnimation(parent: _ctrl, curve: M3Motion.emphasizedEnter), ); } @override void dispose() { _ctrl.dispose(); super.dispose(); } void _onDown(TapDownDetails _) => _ctrl.forward(); void _onUp(TapUpDetails _) => _ctrl.reverse(); void _onCancel() => _ctrl.reverse(); @override Widget build(BuildContext context) { // Skip animation when the OS requests reduced motion. if (m3ReducedMotion(context)) { return GestureDetector( onTap: widget.onTap, onLongPress: widget.onLongPress, child: widget.child, ); } return GestureDetector( onTapDown: _onDown, onTapUp: _onUp, onTapCancel: _onCancel, onTap: widget.onTap, onLongPress: widget.onLongPress, child: ScaleTransition(scale: _scaleAnim, child: widget.child), ); } } // ═══════════════════════════════════════════════════════════════ // M3ErrorShake — horizontal shake animation for validation errors // ═══════════════════════════════════════════════════════════════ /// Plays a brief horizontal shake animation whenever [hasError] transitions /// from `false` to `true` — ideal for wrapping form cards or individual /// fields to signal a validation failure. /// /// Automatically skips the animation when the OS requests reduced motion. /// /// ```dart /// M3ErrorShake( /// hasError: _isLoading == false && _errorMessage != null, /// child: Card(child: Form(...)), /// ) /// ``` class M3ErrorShake extends StatefulWidget { const M3ErrorShake({ super.key, required this.child, required this.hasError, }); final Widget child; /// When this transitions from `false` → `true` the shake fires once. final bool hasError; @override State createState() => _M3ErrorShakeState(); } class _M3ErrorShakeState extends State with SingleTickerProviderStateMixin { late final AnimationController _ctrl; bool _prevError = false; @override void initState() { super.initState(); _ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 450), ); } @override void didUpdateWidget(M3ErrorShake oldWidget) { super.didUpdateWidget(oldWidget); if (widget.hasError && !_prevError) { _ctrl.forward(from: 0); } _prevError = widget.hasError; } @override void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (m3ReducedMotion(context)) return widget.child; return AnimatedBuilder( animation: _ctrl, builder: (context, child) { // Damped oscillation: amplitude fades as t → 1. final t = _ctrl.value; final dx = math.sin(t * math.pi * 5) * 8.0 * (1.0 - t); return Transform.translate(offset: Offset(dx, 0), child: child); }, child: widget.child, ); } } // ═══════════════════════════════════════════════════════════════ // M3BounceIcon — entrance + idle pulse for empty/error states // ═══════════════════════════════════════════════════════════════ /// Displays an [icon] inside a colored circular badge with a spring-bounce /// entrance animation: scales from 0 → 1.0 with a slight overshoot. /// /// Respects [m3ReducedMotion] — the animation is skipped when reduced /// motion is enabled, showing the badge immediately. class M3BounceIcon extends StatefulWidget { const M3BounceIcon({ super.key, required this.icon, required this.iconColor, required this.backgroundColor, this.size = 72.0, this.iconSize = 36.0, }); final IconData icon; final Color iconColor; final Color backgroundColor; final double size; final double iconSize; @override State createState() => _M3BounceIconState(); } class _M3BounceIconState extends State with SingleTickerProviderStateMixin { late final AnimationController _entranceCtrl; late final Animation _entrance; @override void initState() { super.initState(); // One-shot spring entrance: scale 0 → 1.0 with natural overshoot. _entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long); _entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring); _entranceCtrl.forward(); } @override void dispose() { _entranceCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final badge = Container( width: widget.size, height: widget.size, decoration: BoxDecoration( color: widget.backgroundColor, shape: BoxShape.circle, ), child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor), ); if (m3ReducedMotion(context)) return badge; return ScaleTransition(scale: _entrance, child: badge); } } // ═══════════════════════════════════════════════════════════════ // M3AnimatedCounter — smooth number animation // ═══════════════════════════════════════════════════════════════ /// Animates an integer value from its previous value to [value] using a /// [TweenAnimationBuilder], producing a smooth counting effect on metric /// cards and dashboard KPIs. /// /// ```dart /// M3AnimatedCounter( /// value: totalTasks, /// style: theme.textTheme.headlineMedium, /// ) /// ``` class M3AnimatedCounter extends StatelessWidget { const M3AnimatedCounter({ super.key, required this.value, this.style, this.duration = M3Motion.standard, }); final int value; final TextStyle? style; final Duration duration; @override Widget build(BuildContext context) { if (m3ReducedMotion(context)) { return Text(value.toString(), style: style); } return TweenAnimationBuilder( tween: IntTween(begin: 0, end: value), duration: duration, curve: M3Motion.emphasizedEnter, builder: (_, v, _) => Text(v.toString(), style: style), ); } }