feat(app): App-Fundament – Flutter-Gerüst und Supabase-Konfiguration #32

Merged
cschlaefke merged 11 commits from feature/31-app-fundament into main 2026-11-02 21:21:02 +00:00
5 changed files with 431 additions and 6 deletions
Showing only changes of commit 936ca7bd4b - Show all commits

View file

@ -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,

View 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;
}

View file

@ -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,
),
);
}
}

View 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
View 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).