chain-studio/lib/theme/theme.dart
flemming-it 22a851b7e2 fix(studio): doctor i18n + light-mode AppBar contrast (v0.33.1)
Two reports:

1. "In Diagnose ist viel nicht übersetzt, wie pending, chain
   etc." The Doctor summary strip (Modules / Approvals / Audit
   / Services tiles) and the modules panel still rendered
   English wire labels: "pending", "chain", "declared",
   "loaded", "empty", "attention", "No pending approvals",
   "No host services declared", and the matching "{n} modules
   · {m} capabilities" / "{n} approvals awaiting review"
   strings.

   Fix: 13 new ARB keys covering the summary tiles, modules
   panel, and services panel; doctor.dart now reads them via
   AppLocalizations. German operators no longer see English
   labels on Diagnose.

2. "Im Light mode ist der kontrast zur überschrift falsch,
   store ist weiß auf weiß." The AppBar title rendered
   white-on-white in light mode.

   Cause: `appBarTheme.titleTextStyle = textTheme.headlineSmall`
   passed a TextStyle built fresh from GoogleFonts.inter(...)
   with `color: null`. Material's "merge foregroundColor at
   draw time" path didn't always populate it — depended on
   build configuration. The colour fell through to whatever
   the surrounding DefaultTextStyle had, which on the
   light-mode AppBar was the surface colour.

   Fix: bake the foreground colour straight into the title
   style via `copyWith(color: scheme.onSurface)`. Also pin
   `iconTheme: IconThemeData(color: scheme.onSurface)` so
   actions-row icons get the same treatment defensively.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 13:43:17 +02:00

244 lines
8.1 KiB
Dart

// Theme assembly. ColorScheme + typography + component themes.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart';
class FaiTheme {
FaiTheme._();
/// Inter for UI, JetBrains Mono for technical strings (IDs,
/// paths, capability refs). Both via google_fonts so they
/// bundle on first run; for production we'd ship them as
/// app assets.
static TextTheme _textTheme(Brightness b) {
final base = b == Brightness.dark
? Typography.whiteCupertino
: Typography.blackCupertino;
return GoogleFonts.interTextTheme(base).copyWith(
displaySmall: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
headlineSmall: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
titleMedium: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0,
),
titleSmall: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.45,
),
bodyMedium: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.4,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.4,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
);
}
/// JetBrains Mono for monospaced technical content. Used by
/// FaiMono helper.
static TextStyle mono({
double size = 12,
FontWeight weight = FontWeight.w400,
Color? color,
}) =>
GoogleFonts.jetBrainsMono(
fontSize: size,
fontWeight: weight,
color: color,
height: 1.4,
);
static ThemeData dark() {
final scheme = ColorScheme(
brightness: Brightness.dark,
primary: FaiColors.accent,
onPrimary: FaiColors.black,
primaryContainer: FaiColors.accentMuted,
onPrimaryContainer: FaiColors.textStrong,
secondary: FaiColors.accentDeep,
onSecondary: FaiColors.black,
secondaryContainer: FaiColors.surfaceHigh,
onSecondaryContainer: FaiColors.text,
tertiary: FaiColors.accent,
onTertiary: FaiColors.black,
tertiaryContainer: FaiColors.surfaceHigh,
onTertiaryContainer: FaiColors.text,
error: FaiColors.danger,
onError: FaiColors.textStrong,
errorContainer: const Color(0xFF3F1518),
onErrorContainer: const Color(0xFFFCA5A5),
surface: FaiColors.black,
onSurface: FaiColors.text,
onSurfaceVariant: FaiColors.muted,
outline: FaiColors.border,
outlineVariant: FaiColors.border,
surfaceContainerLowest: FaiColors.black,
surfaceContainerLow: FaiColors.surface,
surfaceContainer: FaiColors.surface,
surfaceContainerHigh: FaiColors.surfaceHigh,
surfaceContainerHighest: FaiColors.surfaceHigh,
);
return _build(scheme);
}
static ThemeData light() {
final scheme = ColorScheme(
brightness: Brightness.light,
primary: FaiColors.accentDeep,
onPrimary: FaiColors.lightSurface,
primaryContainer: const Color(0xFFE0F2FE),
onPrimaryContainer: FaiColors.accentMuted,
secondary: FaiColors.accentMuted,
onSecondary: FaiColors.lightSurface,
secondaryContainer: FaiColors.lightSurfaceHigh,
onSecondaryContainer: FaiColors.lightText,
tertiary: FaiColors.accentDeep,
onTertiary: FaiColors.lightSurface,
tertiaryContainer: const Color(0xFFE0F2FE),
onTertiaryContainer: FaiColors.accentMuted,
error: FaiColors.danger,
onError: FaiColors.lightSurface,
errorContainer: const Color(0xFFFEE2E2),
onErrorContainer: const Color(0xFF7F1D1D),
surface: FaiColors.lightCanvas,
onSurface: FaiColors.lightText,
onSurfaceVariant: FaiColors.lightMuted,
outline: FaiColors.lightBorder,
outlineVariant: FaiColors.lightBorder,
surfaceContainerLowest: FaiColors.lightCanvas,
surfaceContainerLow: FaiColors.lightSurface,
surfaceContainer: FaiColors.lightSurface,
surfaceContainerHigh: FaiColors.lightSurfaceHigh,
surfaceContainerHighest: FaiColors.lightSurfaceHigh,
);
return _build(scheme);
}
static ThemeData _build(ColorScheme scheme) {
final textTheme = _textTheme(scheme.brightness);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
textTheme: textTheme,
primaryTextTheme: textTheme,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
iconTheme: IconThemeData(color: scheme.onSurface),
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
// Bake the foreground colour straight into the title
// style. `headlineSmall` is built from GoogleFonts.inter
// with no color slot, and Material's "merge foreground
// colour at draw time" path leaves it null in some
// light-mode builds — the title rendered white-on-white.
titleTextStyle: textTheme.headlineSmall?.copyWith(
color: scheme.onSurface,
),
toolbarHeight: 64,
),
cardTheme: CardThemeData(
color: scheme.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
side: BorderSide(color: scheme.outlineVariant),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: scheme.onSurface,
side: BorderSide(color: scheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.lg,
vertical: FaiSpace.md,
),
textStyle: textTheme.labelMedium,
animationDuration: FaiMotion.fast,
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: scheme.onSurfaceVariant,
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer,
selectedIconTheme: IconThemeData(color: scheme.primary, size: 22),
unselectedIconTheme:
IconThemeData(color: scheme.onSurfaceVariant, size: 22),
selectedLabelTextStyle:
textTheme.labelMedium?.copyWith(color: scheme.primary),
unselectedLabelTextStyle: textTheme.labelMedium
?.copyWith(color: scheme.onSurfaceVariant),
useIndicator: true,
),
dividerColor: scheme.outlineVariant,
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.surfaceContainerHigh,
contentTextStyle:
textTheme.bodyMedium?.copyWith(color: scheme.onSurface),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
),
),
);
}
}