From 936ca7bd4b363005dc71f49c430b2146916eacb3 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Mon, 2 Nov 2026 20:19:19 +0100 Subject: [PATCH] feat(theme): add Material 3 light/dark theme base and design tokens Refs #31 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/app.dart | 2 + app/lib/core/theme/app_spacing.dart | 59 +++++++++ app/lib/core/theme/app_theme.dart | 151 +++++++++++++++++++++- app/test/core/theme/app_theme_test.dart | 164 ++++++++++++++++++++++++ docs/design-system.md | 61 +++++++++ 5 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 app/lib/core/theme/app_spacing.dart create mode 100644 app/test/core/theme/app_theme_test.dart create mode 100644 docs/design-system.md diff --git a/app/lib/app.dart b/app/lib/app.dart index cddfa61..31dfbc0 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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, diff --git a/app/lib/core/theme/app_spacing.dart b/app/lib/core/theme/app_spacing.dart new file mode 100644 index 0000000..0ced66e --- /dev/null +++ b/app/lib/core/theme/app_spacing.dart @@ -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; +} diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index c9d9454..08e82d6 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -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( + (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( + (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, + ), + ); + } } diff --git a/app/test/core/theme/app_theme_test.dart b/app/test/core/theme/app_theme_test.dart new file mode 100644 index 0000000..e08edcc --- /dev/null +++ b/app/test/core/theme/app_theme_test.dart @@ -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 = { + '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 = { + '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); + }); +} diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..bc85daf --- /dev/null +++ b/docs/design-system.md @@ -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).