M3 Overhaul

This commit is contained in:
2026-03-06 20:03:32 +08:00
parent 82fe619f22
commit 73dc735cce
32 changed files with 1940 additions and 682 deletions
+516
View File
@@ -0,0 +1,516 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// 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) {
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,
);
}