892 lines
28 KiB
Dart
892 lines
28 KiB
Dart
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<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) {
|
||
// 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<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,
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 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<M3ShimmerBox> createState() => _M3ShimmerBoxState();
|
||
}
|
||
|
||
class _M3ShimmerBoxState extends State<M3ShimmerBox>
|
||
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<M3PressScale> createState() => _M3PressScaleState();
|
||
}
|
||
|
||
class _M3PressScaleState extends State<M3PressScale>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _ctrl;
|
||
late final Animation<double> _scaleAnim;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(vsync: this, duration: M3Motion.micro);
|
||
_scaleAnim = Tween<double>(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<M3ErrorShake> createState() => _M3ErrorShakeState();
|
||
}
|
||
|
||
class _M3ErrorShakeState extends State<M3ErrorShake>
|
||
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<M3BounceIcon> createState() => _M3BounceIconState();
|
||
}
|
||
|
||
class _M3BounceIconState extends State<M3BounceIcon>
|
||
with SingleTickerProviderStateMixin {
|
||
late final AnimationController _entranceCtrl;
|
||
late final Animation<double> _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<int>(
|
||
tween: IntTween(begin: 0, end: value),
|
||
duration: duration,
|
||
curve: M3Motion.emphasizedEnter,
|
||
builder: (_, v, _) => Text(v.toString(), style: style),
|
||
);
|
||
}
|
||
}
|