M3 Overhaul
This commit is contained in:
+38
-37
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user