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
+38 -37
View File
@@ -1,22 +1,34 @@
import 'package:flutter/material.dart';
/// M3 Expressive surface tokens.
///
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows.
/// Large containers adopt the M3 standard 28 dp corner radius; compact items
/// use 16 dp; small chips/badges use 12 dp.
@immutable
class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces({
required this.cardRadius,
required this.compactCardRadius,
required this.containerRadius,
required this.dialogRadius,
required this.cardElevation,
required this.cardShadowColor,
required this.compactShadowColor,
required this.chipRadius,
});
/// Standard card radius 16 dp (M3 medium shape).
final double cardRadius;
/// Compact card radius for dense list tiles 12 dp.
final double compactCardRadius;
/// Large container radius 28 dp (M3 Expressive).
final double containerRadius;
/// Dialog / bottom-sheet radius 28 dp.
final double dialogRadius;
final double cardElevation;
final Color cardShadowColor;
final Color compactShadowColor;
/// Chip / badge radius 12 dp.
final double chipRadius;
// convenience shapes
RoundedRectangleBorder get standardShape =>
@@ -24,6 +36,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
RoundedRectangleBorder get compactShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(compactCardRadius),
);
RoundedRectangleBorder get containerShape => RoundedRectangleBorder(
borderRadius: BorderRadius.circular(containerRadius),
);
RoundedRectangleBorder get dialogShape =>
RoundedRectangleBorder(borderRadius: BorderRadius.circular(dialogRadius));
@@ -33,10 +48,9 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: Color.fromRGBO(0, 0, 0, 0.12),
compactShadowColor: Color.fromRGBO(0, 0, 0, 0.08),
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
);
}
@@ -44,18 +58,16 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces copyWith({
double? cardRadius,
double? compactCardRadius,
double? containerRadius,
double? dialogRadius,
double? cardElevation,
Color? cardShadowColor,
Color? compactShadowColor,
double? chipRadius,
}) {
return AppSurfaces(
cardRadius: cardRadius ?? this.cardRadius,
compactCardRadius: compactCardRadius ?? this.compactCardRadius,
containerRadius: containerRadius ?? this.containerRadius,
dialogRadius: dialogRadius ?? this.dialogRadius,
cardElevation: cardElevation ?? this.cardElevation,
cardShadowColor: cardShadowColor ?? this.cardShadowColor,
compactShadowColor: compactShadowColor ?? this.compactShadowColor,
chipRadius: chipRadius ?? this.chipRadius,
);
}
@@ -63,28 +75,17 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
AppSurfaces lerp(ThemeExtension<AppSurfaces>? other, double t) {
if (other is! AppSurfaces) return this;
return AppSurfaces(
cardRadius: lerpDouble(cardRadius, other.cardRadius, t) ?? cardRadius,
compactCardRadius:
lerpDouble(compactCardRadius, other.compactCardRadius, t) ??
compactCardRadius,
dialogRadius:
lerpDouble(dialogRadius, other.dialogRadius, t) ?? dialogRadius,
cardElevation:
lerpDouble(cardElevation, other.cardElevation, t) ?? cardElevation,
cardShadowColor:
Color.lerp(cardShadowColor, other.cardShadowColor, t) ??
cardShadowColor,
compactShadowColor:
Color.lerp(compactShadowColor, other.compactShadowColor, t) ??
compactShadowColor,
cardRadius: _lerpDouble(cardRadius, other.cardRadius, t),
compactCardRadius: _lerpDouble(
compactCardRadius,
other.compactCardRadius,
t,
),
containerRadius: _lerpDouble(containerRadius, other.containerRadius, t),
dialogRadius: _lerpDouble(dialogRadius, other.dialogRadius, t),
chipRadius: _lerpDouble(chipRadius, other.chipRadius, t),
);
}
}
// Helper because dart:ui lerpDouble isn't exported here
double? lerpDouble(num? a, num? b, double t) {
if (a == null && b == null) return null;
a = a ?? 0;
b = b ?? 0;
return a + (b - a) * t;
}
double _lerpDouble(double a, double b, double t) => a + (b - a) * t;
+233 -166
View File
@@ -4,143 +4,59 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_typography.dart';
import 'app_surfaces.dart';
/// M3 Expressive theme for TasQ.
///
/// Key differences from the previous Hybrid M2/M3 theme:
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of
/// drop-shadows, giving surfaces an organic, seed-tinted look.
/// * Large containers use the M3 standard **28 dp** corner radius.
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated,
/// Outlined, and Text.
/// * NavigationBar / NavigationRail use pill-shaped indicators with the
/// secondary-container tonal color.
/// * Spring-physics inspired durations: transitions default to 400 ms with an
/// emphasized easing curve.
class AppTheme {
/// The seed color drives M3's entire tonal palette generation.
static const Color _seed = Color(0xFF4A6FA5);
// ────────────────────────────────────────────────────────────
// LIGHT
// ────────────────────────────────────────────────────────────
static ThemeData light() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155),
seedColor: _seed,
brightness: Brightness.light,
),
useMaterial3: true,
);
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
final mono = AppMonoText(
label:
monoTheme.labelMedium?.copyWith(letterSpacing: 0.3) ??
const TextStyle(letterSpacing: 0.3),
body:
monoTheme.bodyMedium?.copyWith(letterSpacing: 0.2) ??
const TextStyle(letterSpacing: 0.2),
);
final surfaces = AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.08),
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: base.colorScheme.surfaceContainerLowest,
extensions: [mono, surfaces],
appBarTheme: AppBarTheme(
backgroundColor: base.colorScheme.surface,
foregroundColor: base.colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
surfaceTintColor: base.colorScheme.surfaceTint,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
cardTheme: CardThemeData(
color: base.colorScheme.surface,
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
margin: EdgeInsets.zero,
shadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
),
),
chipTheme: ChipThemeData(
backgroundColor: base.colorScheme.surfaceContainerHighest,
side: BorderSide(color: base.colorScheme.outlineVariant),
labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
dividerTheme: DividerThemeData(
color: base.colorScheme.outlineVariant,
thickness: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: base.colorScheme.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: base.colorScheme.outline),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
),
),
navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.secondaryContainer,
tileHeight: 52,
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: base.colorScheme.surface,
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
indicatorColor: base.colorScheme.secondaryContainer,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.primaryContainer,
labelTextStyle: WidgetStateProperty.all(
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
tileColor: base.colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
);
return _apply(base);
}
// ────────────────────────────────────────────────────────────
// DARK
// ────────────────────────────────────────────────────────────
static ThemeData dark() {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF334155),
seedColor: _seed,
brightness: Brightness.dark,
),
useMaterial3: true,
);
return _apply(base);
}
// ────────────────────────────────────────────────────────────
// SHARED BUILDER
// ────────────────────────────────────────────────────────────
static ThemeData _apply(ThemeData base) {
final cs = base.colorScheme;
final isDark = cs.brightness == Brightness.dark;
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
final mono = AppMonoText(
@@ -152,110 +68,261 @@ class AppTheme {
const TextStyle(letterSpacing: 0.2),
);
final surfaces = AppSurfaces(
const surfaces = AppSurfaces(
cardRadius: 16,
compactCardRadius: 12,
dialogRadius: 20,
cardElevation: 3,
cardShadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
compactShadowColor: const Color.fromRGBO(0, 0, 0, 0.12),
containerRadius: 28,
dialogRadius: 28,
chipRadius: 12,
);
return base.copyWith(
textTheme: textTheme,
scaffoldBackgroundColor: base.colorScheme.surface,
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
extensions: [mono, surfaces],
// ── AppBar ──────────────────────────────────────────────
appBarTheme: AppBarTheme(
backgroundColor: base.colorScheme.surface,
foregroundColor: base.colorScheme.onSurface,
backgroundColor: cs.surface,
foregroundColor: cs.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
surfaceTintColor: base.colorScheme.surfaceTint,
scrolledUnderElevation: 2,
surfaceTintColor: cs.surfaceTint,
centerTitle: false,
titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
// ── Cards — M3 Elevated (tonal surface tint, no hard shadow) ──
cardTheme: CardThemeData(
color: base.colorScheme.surfaceContainer,
elevation: 3, // M2-style elevation for visible separation (2-4 allowed)
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
elevation: 1,
margin: EdgeInsets.zero,
shadowColor: const Color.fromRGBO(0, 0, 0, 0.24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: base.colorScheme.outlineVariant, width: 1),
),
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// ── Chips ───────────────────────────────────────────────
chipTheme: ChipThemeData(
backgroundColor: base.colorScheme.surfaceContainerHighest,
side: BorderSide(color: base.colorScheme.outlineVariant),
backgroundColor: cs.surfaceContainerHighest,
side: BorderSide.none,
labelStyle: textTheme.labelSmall,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
dividerTheme: DividerThemeData(
color: base.colorScheme.outlineVariant,
thickness: 1,
),
// ── Dividers ────────────────────────────────────────────
dividerTheme: DividerThemeData(color: cs.outlineVariant, thickness: 1),
// ── Input Fields ────────────────────────────────────────
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: base.colorScheme.surfaceContainerLow,
fillColor: cs.surfaceContainerLow,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: base.colorScheme.primary, width: 1.5),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
// ── Buttons — M3 Expressive hierarchy ───────────────────
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: cs.surfaceContainerLow,
foregroundColor: cs.primary,
elevation: 1,
shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
side: BorderSide(color: cs.outline),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
side: BorderSide(color: base.colorScheme.outline),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
// ── FAB — M3 Expressive ──────────────────────────────────
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: cs.primaryContainer,
foregroundColor: cs.onPrimaryContainer,
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
// ── Navigation — M3 Expressive pill indicators ──────────
navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.secondaryContainer,
tileHeight: 52,
backgroundColor: cs.surface,
indicatorColor: cs.secondaryContainer,
tileHeight: 56,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: base.colorScheme.surface,
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
selectedLabelTextStyle: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
backgroundColor: cs.surface,
selectedIconTheme: IconThemeData(color: cs.onSecondaryContainer),
unselectedIconTheme: IconThemeData(color: cs.onSurfaceVariant),
selectedLabelTextStyle: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: cs.onSurface,
),
unselectedLabelTextStyle: textTheme.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
),
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
indicatorColor: base.colorScheme.secondaryContainer,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: base.colorScheme.surface,
indicatorColor: base.colorScheme.primaryContainer,
labelTextStyle: WidgetStateProperty.all(
textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600),
backgroundColor: cs.surfaceContainer,
indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return textTheme.labelMedium?.copyWith(
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? cs.onSurface : cs.onSurfaceVariant,
);
}),
elevation: 2,
surfaceTintColor: cs.surfaceTint,
),
// ── List Tiles ──────────────────────────────────────────
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
tileColor: base.colorScheme.surfaceContainer,
tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
// ── Dialogs ─────────────────────────────────────────────
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
surfaceTintColor: cs.surfaceTint,
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
),
// ── Bottom Sheets ───────────────────────────────────────
bottomSheetTheme: BottomSheetThemeData(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
backgroundColor: isDark
? cs.surfaceContainerHigh
: cs.surfaceContainerLowest,
surfaceTintColor: cs.surfaceTint,
showDragHandle: true,
),
// ── Snackbar ────────────────────────────────────────────
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
// ── Search Bar ──────────────────────────────────────────
searchBarTheme: SearchBarThemeData(
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
),
),
// ── Tooltips ────────────────────────────────────────────
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: cs.inverseSurface,
borderRadius: BorderRadius.circular(8),
),
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
),
// ── Tab Bar ─────────────────────────────────────────────
tabBarTheme: TabBarThemeData(
labelStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700),
unselectedLabelStyle: textTheme.labelLarge,
indicatorColor: cs.primary,
labelColor: cs.primary,
unselectedLabelColor: cs.onSurfaceVariant,
indicatorSize: TabBarIndicatorSize.label,
dividerColor: cs.outlineVariant,
),
// ── PopupMenu ───────────────────────────────────────────
popupMenuTheme: PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
surfaceTintColor: cs.surfaceTint,
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
),
);
}
}
+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,
);
}