Enhanced material design 3 implementation

This commit is contained in:
2026-03-20 15:15:38 +08:00
parent 27ebb89052
commit 74197c525d
26 changed files with 1345 additions and 515 deletions
+399
View File
@@ -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),
);
}
}