149 lines
5.7 KiB
Dart
149 lines
5.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:shuffle/core/theme/app_spacing.dart';
|
|
|
|
/// Material 3 theme base of the design system (see docs/design-system.md).
|
|
///
|
|
/// Colors come from [ColorScheme.fromSeed]; the contrast of all core pairs is
|
|
/// verified in `test/core/theme/app_theme_test.dart` (WCAG AA, >= 4.5:1 for
|
|
/// text, >= 3:1 for the focus indicator and input borders).
|
|
abstract final class AppTheme {
|
|
static const Color seed = Color(0xFF4F46E5);
|
|
|
|
static ThemeData light() => _build(Brightness.light);
|
|
|
|
static ThemeData dark() => _build(Brightness.dark);
|
|
|
|
static ColorScheme _scheme(Brightness brightness) {
|
|
final base = ColorScheme.fromSeed(seedColor: seed, brightness: brightness);
|
|
// Tone 50/60 outlines keep >= 3:1 against the surface (WCAG 1.4.11);
|
|
// the M3 default outline is slightly weaker in light mode.
|
|
return base.copyWith(
|
|
outline: brightness == Brightness.light
|
|
? const Color(0xFF6B6A7A)
|
|
: const Color(0xFF9A99AB),
|
|
);
|
|
}
|
|
|
|
static ThemeData _build(Brightness brightness) {
|
|
final scheme = _scheme(brightness);
|
|
final textTheme = Typography.material2021(colorScheme: scheme).black
|
|
.apply(bodyColor: scheme.onSurface, displayColor: scheme.onSurface);
|
|
final themedText = brightness == Brightness.dark
|
|
? Typography.material2021(colorScheme: scheme).white.apply(
|
|
bodyColor: scheme.onSurface,
|
|
displayColor: scheme.onSurface,
|
|
)
|
|
: textTheme;
|
|
|
|
const minSize = Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget);
|
|
final shape = RoundedRectangleBorder(borderRadius: AppRadius.mdAll);
|
|
// Visible keyboard focus ring (web/desktop), contrast >= 3:1.
|
|
final focusSide = WidgetStateProperty.resolveWith<BorderSide?>(
|
|
(states) => states.contains(WidgetState.focused)
|
|
? BorderSide(color: scheme.primary, width: 3)
|
|
: null,
|
|
);
|
|
|
|
OutlineInputBorder border(Color color, double width) => OutlineInputBorder(
|
|
borderRadius: AppRadius.mdAll,
|
|
borderSide: BorderSide(color: color, width: width),
|
|
);
|
|
|
|
return ThemeData(
|
|
useMaterial3: true,
|
|
colorScheme: scheme,
|
|
textTheme: themedText,
|
|
visualDensity: VisualDensity.standard,
|
|
materialTapTargetSize: MaterialTapTargetSize.padded,
|
|
scaffoldBackgroundColor: scheme.surface,
|
|
focusColor: scheme.primary.withValues(alpha: 0.16),
|
|
dividerTheme: DividerThemeData(color: scheme.outlineVariant),
|
|
appBarTheme: AppBarTheme(
|
|
backgroundColor: scheme.surface,
|
|
foregroundColor: scheme.onSurface,
|
|
centerTitle: false,
|
|
titleTextStyle: themedText.titleLarge,
|
|
),
|
|
cardTheme: CardThemeData(
|
|
elevation: 0,
|
|
color: scheme.surfaceContainerLow,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: AppRadius.lgAll,
|
|
side: BorderSide(color: scheme.outlineVariant),
|
|
),
|
|
),
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: minSize,
|
|
shape: shape,
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
|
).copyWith(side: focusSide),
|
|
),
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: minSize,
|
|
shape: shape,
|
|
).copyWith(side: focusSide),
|
|
),
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style:
|
|
OutlinedButton.styleFrom(
|
|
minimumSize: minSize,
|
|
shape: shape,
|
|
side: BorderSide(color: scheme.outline),
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
|
|
).copyWith(
|
|
side: WidgetStateProperty.resolveWith<BorderSide?>(
|
|
(states) => states.contains(WidgetState.focused)
|
|
? BorderSide(color: scheme.primary, width: 3)
|
|
: BorderSide(color: scheme.outline),
|
|
),
|
|
),
|
|
),
|
|
textButtonTheme: TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
minimumSize: minSize,
|
|
shape: shape,
|
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
|
).copyWith(side: focusSide),
|
|
),
|
|
iconButtonTheme: IconButtonThemeData(
|
|
style: IconButton.styleFrom(minimumSize: minSize),
|
|
),
|
|
checkboxTheme: CheckboxThemeData(
|
|
materialTapTargetSize: MaterialTapTargetSize.padded,
|
|
side: BorderSide(color: scheme.outline, width: 2),
|
|
),
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: scheme.surfaceContainerHighest,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.lg,
|
|
vertical: AppSpacing.md,
|
|
),
|
|
border: border(scheme.outline, 1),
|
|
enabledBorder: border(scheme.outline, 1),
|
|
focusedBorder: border(scheme.primary, 3),
|
|
errorBorder: border(scheme.error, 2),
|
|
focusedErrorBorder: border(scheme.error, 3),
|
|
errorStyle: themedText.bodySmall?.copyWith(color: scheme.error),
|
|
helperStyle: themedText.bodySmall?.copyWith(
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
snackBarTheme: SnackBarThemeData(
|
|
behavior: SnackBarBehavior.floating,
|
|
backgroundColor: scheme.inverseSurface,
|
|
contentTextStyle: themedText.bodyMedium?.copyWith(
|
|
color: scheme.onInverseSurface,
|
|
),
|
|
actionTextColor: scheme.inversePrimary,
|
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.smAll),
|
|
showCloseIcon: true,
|
|
closeIconColor: scheme.onInverseSurface,
|
|
// Wide screens: do not stretch across the whole window.
|
|
width: null,
|
|
),
|
|
);
|
|
}
|
|
}
|