feat(theme): add Material 3 light/dark theme base and design tokens
Refs #31 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
77c71c0169
commit
936ca7bd4b
5 changed files with 431 additions and 6 deletions
|
|
@ -14,6 +14,8 @@ class ShuffleApp extends ConsumerWidget {
|
|||
return MaterialApp.router(
|
||||
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: ref.watch(appRouterProvider),
|
||||
locale: ref.watch(appLocaleProvider),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
|
|
|||
59
app/lib/core/theme/app_spacing.dart
Normal file
59
app/lib/core/theme/app_spacing.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Spacing tokens (dp) on a 4dp grid.
|
||||
abstract final class AppSpacing {
|
||||
static const double xxs = 2;
|
||||
static const double xs = 4;
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 24;
|
||||
static const double xxl = 32;
|
||||
static const double xxxl = 48;
|
||||
|
||||
/// Minimum size of interactive targets (WCAG 2.5.5 / Material: 48dp).
|
||||
static const double minTouchTarget = 48;
|
||||
|
||||
/// Page padding per width class.
|
||||
static EdgeInsets pagePadding(double width) => EdgeInsets.symmetric(
|
||||
horizontal: width >= AppBreakpoints.expanded
|
||||
? xxl
|
||||
: width >= AppBreakpoints.medium
|
||||
? xl
|
||||
: lg,
|
||||
vertical: lg,
|
||||
);
|
||||
}
|
||||
|
||||
/// Corner radius tokens (dp).
|
||||
abstract final class AppRadius {
|
||||
static const double sm = 8;
|
||||
static const double md = 12;
|
||||
static const double lg = 16;
|
||||
static const double xl = 28;
|
||||
|
||||
static BorderRadius get smAll => BorderRadius.circular(sm);
|
||||
static BorderRadius get mdAll => BorderRadius.circular(md);
|
||||
static BorderRadius get lgAll => BorderRadius.circular(lg);
|
||||
}
|
||||
|
||||
/// Width breakpoints: compact < 600, medium 600-1023, expanded >= 1024.
|
||||
abstract final class AppBreakpoints {
|
||||
static const double medium = 600;
|
||||
static const double expanded = 1024;
|
||||
|
||||
/// Max content width for forms/text on wide screens.
|
||||
static const double maxContentWidth = 560;
|
||||
}
|
||||
|
||||
enum AppWidthClass {
|
||||
compact,
|
||||
medium,
|
||||
expanded;
|
||||
|
||||
static AppWidthClass of(double width) => width >= AppBreakpoints.expanded
|
||||
? expanded
|
||||
: width >= AppBreakpoints.medium
|
||||
? medium
|
||||
: compact;
|
||||
}
|
||||
|
|
@ -1,10 +1,149 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:shuffle/core/theme/app_spacing.dart';
|
||||
|
||||
/// Minimales Platzhalter-Theme. Wird vom Design-System (F6, ui-designer)
|
||||
/// ersetzt; `ShuffleApp` greift nur auf [AppTheme.light] zu.
|
||||
/// 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 ThemeData light() => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
);
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
app/test/core/theme/app_theme_test.dart
Normal file
164
app/test/core/theme/app_theme_test.dart
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shuffle/core/theme/app_spacing.dart';
|
||||
import 'package:shuffle/core/theme/app_theme.dart';
|
||||
|
||||
double _lum(Color c) => c.computeLuminance();
|
||||
|
||||
double contrast(Color a, Color b) {
|
||||
final l1 = _lum(a);
|
||||
final l2 = _lum(b);
|
||||
final hi = l1 > l2 ? l1 : l2;
|
||||
final lo = l1 > l2 ? l2 : l1;
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
}
|
||||
|
||||
void main() {
|
||||
final themes = {'light': AppTheme.light(), 'dark': AppTheme.dark()};
|
||||
|
||||
for (final entry in themes.entries) {
|
||||
final name = entry.key;
|
||||
final theme = entry.value;
|
||||
final s = theme.colorScheme;
|
||||
|
||||
group('$name theme', () {
|
||||
test('uses Material 3 and matching brightness', () {
|
||||
expect(theme.useMaterial3, isTrue);
|
||||
expect(
|
||||
s.brightness,
|
||||
name == 'light' ? Brightness.light : Brightness.dark,
|
||||
);
|
||||
});
|
||||
|
||||
test('text pairs have contrast >= 4.5:1', () {
|
||||
final pairs = <String, (Color, Color)>{
|
||||
'onSurface/surface': (s.onSurface, s.surface),
|
||||
'onSurfaceVariant/surface': (s.onSurfaceVariant, s.surface),
|
||||
'onSurface/surfaceContainerLow': (s.onSurface, s.surfaceContainerLow),
|
||||
'onSurfaceVariant/surfaceContainerHighest': (
|
||||
s.onSurfaceVariant,
|
||||
s.surfaceContainerHighest,
|
||||
),
|
||||
'onPrimary/primary': (s.onPrimary, s.primary),
|
||||
'onSecondary/secondary': (s.onSecondary, s.secondary),
|
||||
'onError/error': (s.onError, s.error),
|
||||
'onPrimaryContainer/primaryContainer': (
|
||||
s.onPrimaryContainer,
|
||||
s.primaryContainer,
|
||||
),
|
||||
'onErrorContainer/errorContainer': (
|
||||
s.onErrorContainer,
|
||||
s.errorContainer,
|
||||
),
|
||||
'error/surface': (s.error, s.surface),
|
||||
'error/inputFill': (s.error, s.surfaceContainerHighest),
|
||||
'primary/surface (text buttons)': (s.primary, s.surface),
|
||||
'onInverseSurface/inverseSurface': (
|
||||
s.onInverseSurface,
|
||||
s.inverseSurface,
|
||||
),
|
||||
'inversePrimary/inverseSurface (snackbar action)': (
|
||||
s.inversePrimary,
|
||||
s.inverseSurface,
|
||||
),
|
||||
};
|
||||
for (final p in pairs.entries) {
|
||||
expect(
|
||||
contrast(p.value.$1, p.value.$2),
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason: p.key,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-text UI parts have contrast >= 3:1', () {
|
||||
final pairs = <String, (Color, Color)>{
|
||||
'outline/surface': (s.outline, s.surface),
|
||||
'outline/inputFill': (s.outline, s.surfaceContainerHighest),
|
||||
'focus ring primary/surface': (s.primary, s.surface),
|
||||
'focus ring primary/inputFill': (
|
||||
s.primary,
|
||||
s.surfaceContainerHighest,
|
||||
),
|
||||
'error border/inputFill': (s.error, s.surfaceContainerHighest),
|
||||
};
|
||||
for (final p in pairs.entries) {
|
||||
expect(
|
||||
contrast(p.value.$1, p.value.$2),
|
||||
greaterThanOrEqualTo(3.0),
|
||||
reason: p.key,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('touch targets are padded and buttons are >= 48dp', () {
|
||||
expect(theme.materialTapTargetSize, MaterialTapTargetSize.padded);
|
||||
final styles = [
|
||||
theme.filledButtonTheme.style,
|
||||
theme.elevatedButtonTheme.style,
|
||||
theme.outlinedButtonTheme.style,
|
||||
theme.textButtonTheme.style,
|
||||
theme.iconButtonTheme.style,
|
||||
];
|
||||
for (final style in styles) {
|
||||
final size = style!.minimumSize!.resolve({})!;
|
||||
expect(size.width, greaterThanOrEqualTo(48));
|
||||
expect(size.height, greaterThanOrEqualTo(48));
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('buttons render >= 48dp and satisfy a11y guidelines', (
|
||||
tester,
|
||||
) async {
|
||||
final handle = tester.ensureSemantics();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FilledButton(onPressed: () {}, child: const Text('Save')),
|
||||
OutlinedButton(onPressed: () {}, child: const Text('Back')),
|
||||
TextButton(onPressed: () {}, child: const Text('More')),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
tooltip: 'Close',
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
const TextField(
|
||||
decoration: InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
||||
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
|
||||
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
||||
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
||||
handle.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('light and dark differ', () {
|
||||
expect(
|
||||
AppTheme.light().colorScheme.surface,
|
||||
isNot(AppTheme.dark().colorScheme.surface),
|
||||
);
|
||||
});
|
||||
|
||||
test('width classes follow breakpoints 600 / 1024', () {
|
||||
expect(AppWidthClass.of(599), AppWidthClass.compact);
|
||||
expect(AppWidthClass.of(600), AppWidthClass.medium);
|
||||
expect(AppWidthClass.of(1023), AppWidthClass.medium);
|
||||
expect(AppWidthClass.of(1024), AppWidthClass.expanded);
|
||||
});
|
||||
|
||||
test('page padding grows with width', () {
|
||||
expect(AppSpacing.pagePadding(400).left, AppSpacing.lg);
|
||||
expect(AppSpacing.pagePadding(800).left, AppSpacing.xl);
|
||||
expect(AppSpacing.pagePadding(1200).left, AppSpacing.xxl);
|
||||
});
|
||||
}
|
||||
61
docs/design-system.md
Normal file
61
docs/design-system.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Design-System (Theme-Basis, F6 / Issue #31)
|
||||
|
||||
Stand: Basis. Konkrete Komponenten (Formularfelder, Banner, Spiel-Kachel usw.) folgen in U1.
|
||||
Code: `app/lib/core/theme/` (`app_theme.dart`, `app_spacing.dart`), Tests: `app/test/core/theme/`.
|
||||
|
||||
## Theme
|
||||
|
||||
- Material 3, `AppTheme.light()` und `AppTheme.dark()`; `ShuffleApp` nutzt `ThemeMode.system`.
|
||||
- Farben: `ColorScheme.fromSeed` mit Seed `#4F46E5` (Indigo). Einzige Abweichung: `outline` ist
|
||||
etwas dunkler (hell `#6B6A7A`) bzw. heller (dunkel `#9A99AB`), damit Feldrahmen mindestens 3:1 erreichen.
|
||||
- Keine Farbwerte in Widgets: immer `Theme.of(context).colorScheme` und `textTheme` verwenden.
|
||||
|
||||
## Kontraste (WCAG 2.1 AA)
|
||||
|
||||
Geprüft im Test `app_theme_test.dart` für Hell und Dunkel. Text mindestens 4.5:1, Bedienelemente
|
||||
(Rahmen, Fokusring, Fehlerrahmen) mindestens 3:1.
|
||||
|
||||
| Paar | Hell | Dunkel |
|
||||
|---|---|---|
|
||||
| onSurface / surface | 16.3 | 14.4 |
|
||||
| onSurfaceVariant / surface | 8.9 | 10.9 |
|
||||
| onSurfaceVariant / Feldfüllung | 7.2 | 7.3 |
|
||||
| onPrimary / primary (Buttons) | 6.5 | 7.7 |
|
||||
| Fehlertext (error) / Feldfüllung | 5.0 | 7.3 |
|
||||
| SnackBar-Text / SnackBar-Fläche | 11.5 | 10.1 |
|
||||
| SnackBar-Aktion / SnackBar-Fläche | 7.7 | 5.0 |
|
||||
| Feldrahmen (outline) / Feldfüllung | 4.1 | 4.4 |
|
||||
| Fokusring (primary) / Feldfüllung | 5.0 | 7.2 |
|
||||
|
||||
Regeln: Informationen nie nur über Farbe vermitteln (Fehler = Text + Rahmen + Icon). Neue Farbpaare
|
||||
müssen in den Test aufgenommen werden. Deaktivierte Elemente sind von der Kontrastpflicht ausgenommen.
|
||||
|
||||
## Touch-Ziele und Fokus
|
||||
|
||||
- Mindestgröße 48 x 48 dp für alle Buttons und Icon-Buttons (`minimumSize`), `MaterialTapTargetSize.padded`,
|
||||
`VisualDensity.standard`. Nicht auf `compact` verkleinern.
|
||||
- Tastaturfokus (Web/Desktop): Buttons und Textfelder zeigen einen 3 dp breiten Rahmen in `primary`.
|
||||
- Icons ohne Text brauchen `tooltip` bzw. `Semantics(label: ...)`; Bilder ein Label oder `excludeSemantics`.
|
||||
- Text muss bis 200 % Skalierung ohne Abschneiden funktionieren (kein fixes Höhen-Layout für Text).
|
||||
|
||||
## Typografie
|
||||
|
||||
Material-2021-Skala (`textTheme`), Farbe `onSurface`. Überschriften `headline*`/`title*`, Fließtext `bodyLarge/Medium`,
|
||||
Hinweise `bodySmall` (`onSurfaceVariant`). Schriftgrößen nie hart codieren.
|
||||
|
||||
## Abstände, Radien, Breakpoints (`app_spacing.dart`)
|
||||
|
||||
| Token | Wert (dp) |
|
||||
|---|---|
|
||||
| `AppSpacing` xxs / xs / sm / md / lg / xl / xxl / xxxl | 2 / 4 / 8 / 12 / 16 / 24 / 32 / 48 |
|
||||
| `AppRadius` sm / md / lg / xl | 8 / 12 / 16 / 28 |
|
||||
| Breakpoints (`AppWidthClass`) | kompakt < 600, mittel 600-1023, groß >= 1024 |
|
||||
| `AppBreakpoints.maxContentWidth` | 560 (Formulare/Text auf breiten Bildschirmen begrenzen) |
|
||||
|
||||
`AppSpacing.pagePadding(width)` liefert den Seitenrand je Breitenklasse (16 / 24 / 32). Web darf nicht
|
||||
wie eine gestreckte Handy-App wirken: Inhalt zentrieren und auf `maxContentWidth` begrenzen.
|
||||
|
||||
## Komponenten-Themes in der Basis
|
||||
|
||||
FilledButton, ElevatedButton, OutlinedButton, TextButton, IconButton, Checkbox, InputDecoration
|
||||
(gefüllt, Rahmen 1 dp, Fokus 3 dp, Fehler 2 dp), Card, AppBar, Divider, SnackBar (schwebend, mit Schließen-Icon).
|
||||
Loading…
Reference in a new issue