Shuffle/app/lib/core/theme/app_theme.dart
cschlaefke b03d771869
Some checks failed
CI / flutter (pull_request) Successful in 2m29s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Failing after 23s
fix(app): address review findings for foundation
- add INTERNET permission, iOS localizations, html lang
- make focus ring visible on FilledButton
- neutral SITE_URL default, idempotent logger configure

Refs #31

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:36:58 +01:00

154 lines
5.9 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,
);
// The primary ring is invisible on the primary fill of a FilledButton;
// onSurface contrasts with both the fill and the surrounding surface.
final filledFocusSide = WidgetStateProperty.resolveWith<BorderSide?>(
(states) => states.contains(WidgetState.focused)
? BorderSide(color: scheme.onSurface, 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: filledFocusSide),
),
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,
),
);
}
}