Implementation
static ThemeData build(
ColorScheme scheme, {
/// Optional font family. Falls back to 'Inter' → system font.
String? fontFamily,
}) {
final tt = AppTypography.textTheme(fontFamily: fontFamily);
final isDark = scheme.brightness == Brightness.dark;
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
textTheme: tt,
// ── AppBar ────────────────────────────────────────────────────────────
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 1,
shadowColor: scheme.shadow,
centerTitle: false,
titleTextStyle: tt.titleLarge?.copyWith(color: scheme.onSurface),
iconTheme: IconThemeData(color: scheme.onSurfaceVariant),
actionsIconTheme: IconThemeData(color: scheme.onSurfaceVariant),
),
// ── Card ──────────────────────────────────────────────────────────────
cardTheme: CardThemeData(
color: scheme.surfaceContainerLow,
shadowColor: scheme.shadow.withValues(alpha: isDark ? 0.4 : 0.12),
elevation: isDark ? 2 : 1,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.card),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
),
// ── Buttons ───────────────────────────────────────────────────────────
/// FilledButton = primary call-to-action in M3.
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(64, 48),
padding: AppSpacing.buttonPadding,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.button),
textStyle: tt.labelLarge,
),
),
/// ElevatedButton = secondary tonal action with slight elevation.
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: scheme.surfaceContainerLow,
foregroundColor: scheme.primary,
shadowColor: scheme.shadow.withValues(alpha: 0.2),
minimumSize: const Size(64, 48),
padding: AppSpacing.buttonPadding,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.button),
elevation: 1,
textStyle: tt.labelLarge,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(64, 48),
padding: AppSpacing.buttonPadding,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.button),
side: BorderSide(color: scheme.outline),
textStyle: tt.labelLarge,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: scheme.primary,
minimumSize: const Size(48, 40),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.x2,
vertical: AppSpacing.x1,
),
shape: const RoundedRectangleBorder(borderRadius: AppRadius.button),
textStyle: tt.labelLarge,
),
),
// ── Input ─────────────────────────────────────────────────────────────
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest,
contentPadding: AppSpacing.inputPadding,
border: OutlineInputBorder(
borderRadius: AppRadius.input,
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.input,
borderSide: BorderSide(
color: scheme.outline.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.input,
borderSide: BorderSide(color: scheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: AppRadius.input,
borderSide: BorderSide(color: scheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: AppRadius.input,
borderSide: BorderSide(color: scheme.error, width: 2),
),
hintStyle: tt.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
labelStyle: tt.bodyMedium?.copyWith(color: scheme.onSurfaceVariant),
errorStyle: tt.bodySmall?.copyWith(color: scheme.error),
floatingLabelStyle: WidgetStateTextStyle.resolveWith((states) {
final base = tt.labelMedium ?? const TextStyle();
if (states.contains(WidgetState.focused)) {
return base.copyWith(color: scheme.primary);
}
if (states.contains(WidgetState.error)) {
return base.copyWith(color: scheme.error);
}
return base.copyWith(color: scheme.onSurfaceVariant);
}),
),
// ── Chip ──────────────────────────────────────────────────────────────
chipTheme: ChipThemeData(
backgroundColor: scheme.surfaceContainerHigh,
selectedColor: scheme.primaryContainer,
disabledColor: scheme.onSurface.withValues(alpha: 0.12),
labelStyle: tt.labelMedium,
secondaryLabelStyle: tt.labelMedium?.copyWith(
color: scheme.onPrimaryContainer,
),
side: BorderSide.none,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.x1_5,
vertical: AppSpacing.x0_5,
),
),
// ── Dialog ────────────────────────────────────────────────────────────
dialogTheme: DialogThemeData(
backgroundColor: scheme.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.dialog),
titleTextStyle: tt.headlineSmall?.copyWith(color: scheme.onSurface),
contentTextStyle: tt.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
),
// ── BottomSheet ───────────────────────────────────────────────────────
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: scheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.bottomSheet),
showDragHandle: true,
dragHandleColor: scheme.onSurfaceVariant.withValues(alpha: 0.4),
dragHandleSize: const Size(32, 4),
modalBarrierColor: scheme.scrim.withValues(alpha: 0.5),
),
// ── Navigation Bar (bottom nav) ───────────────────────────────────────
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surfaceContainer,
surfaceTintColor: Colors.transparent,
indicatorColor: scheme.primaryContainer,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return IconThemeData(color: scheme.onPrimaryContainer, size: 24);
}
return IconThemeData(color: scheme.onSurfaceVariant, size: 24);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final base = tt.labelSmall ?? const TextStyle();
if (states.contains(WidgetState.selected)) {
return base.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w600,
);
}
return base.copyWith(color: scheme.onSurfaceVariant);
}),
height: 80,
elevation: 0,
),
// ── Navigation Rail ───────────────────────────────────────────────────
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer,
selectedIconTheme: IconThemeData(color: scheme.onPrimaryContainer),
unselectedIconTheme: IconThemeData(color: scheme.onSurfaceVariant),
selectedLabelTextStyle: tt.labelMedium?.copyWith(
color: scheme.onSurface,
fontWeight: FontWeight.w600,
),
unselectedLabelTextStyle: tt.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
elevation: 0,
),
// ── Divider ───────────────────────────────────────────────────────────
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
// ── ListTile ──────────────────────────────────────────────────────────
listTileTheme: ListTileThemeData(
tileColor: Colors.transparent,
iconColor: scheme.onSurfaceVariant,
titleTextStyle: tt.bodyLarge?.copyWith(color: scheme.onSurface),
subtitleTextStyle: tt.bodyMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.x2,
vertical: AppSpacing.x0_5,
),
shape: const RoundedRectangleBorder(borderRadius: AppRadius.allMd),
minLeadingWidth: 0,
),
// ── SnackBar ──────────────────────────────────────────────────────────
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.inverseSurface,
contentTextStyle: tt.bodyMedium?.copyWith(
color: scheme.onInverseSurface,
),
actionTextColor: scheme.inversePrimary,
behavior: SnackBarBehavior.floating,
shape: const RoundedRectangleBorder(borderRadius: AppRadius.snackBar),
elevation: 4,
),
// ── Switch ────────────────────────────────────────────────────────────
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.onPrimary;
return scheme.onSurfaceVariant;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.primary;
return scheme.surfaceContainerHighest;
}),
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return Colors.transparent;
}
return scheme.outline;
}),
),
// ── Checkbox ──────────────────────────────────────────────────────────
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.primary;
return Colors.transparent;
}),
checkColor: WidgetStateProperty.all(scheme.onPrimary),
side: BorderSide(color: scheme.outline, width: 1.5),
shape: const RoundedRectangleBorder(
borderRadius: AppRadius.allXs,
),
),
// ── Radio ─────────────────────────────────────────────────────────────
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return scheme.primary;
return scheme.onSurfaceVariant;
}),
),
// ── Tooltip ───────────────────────────────────────────────────────────
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: scheme.inverseSurface,
borderRadius: AppRadius.allXs,
),
textStyle: tt.bodySmall?.copyWith(color: scheme.onInverseSurface),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.x1_5,
vertical: AppSpacing.x0_5,
),
waitDuration: AppMotion.slow,
),
// ── Page transitions ──────────────────────────────────────────────────
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
);
}