tasq/lib/theme/m3_motion.dart

916 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
/// 1. A spring-bounce entrance animation on first build.
/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats
/// indefinitely to draw attention without being distracting.
///
/// Respects [m3ReducedMotion] — both animations are skipped when reduced
/// motion is enabled.
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 TickerProviderStateMixin {
late final AnimationController _entranceCtrl;
late final AnimationController _pulseCtrl;
late final Animation<double> _entrance;
late final Animation<double> _pulse;
@override
void initState() {
super.initState();
// Entrance: scale 0 → 1.0 with spring overshoot.
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
// Idle pulse: 1.0 → 1.06 → 1.0, repeating every 2.5 s.
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2500),
)..repeat(reverse: true);
_pulse = Tween<double>(begin: 1.0, end: 1.06).animate(
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
);
_entranceCtrl.forward();
}
@override
void dispose() {
_entranceCtrl.dispose();
_pulseCtrl.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: AnimatedBuilder(
animation: _pulse,
builder: (_, child) =>
Transform.scale(scale: _pulse.value, child: child),
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),
);
}
}