Enhanced material design 3 implementation
This commit is contained in:
@@ -3,6 +3,15 @@ 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
|
||||
@@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State<M3FadeSlideIn>
|
||||
|
||||
@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),
|
||||
@@ -514,3 +525,391 @@ Future<T?> m3ShowBottomSheet<T>({
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user