// The palette system, from smart-serow's HUD, widened to seven roles: each a meaning, // each with a dark (low ambient light) and a bright (high ambient light) variant, read // from a theme JSON in assets/themes/. Widgets ask for meanings, never for colours: // // final p = AppTheme.of(context); // color: p.foreground // // The roles: background, foreground, subdued (muted), accent (emphasis without danger: // buttons, links, the current item), and the good → caution → alarm gradient for state. // Two more are derived, never authored: surface (raised bars and panels) and onFill // (text on a filled colour). // // smart-serow's four-key files load unchanged: their `highlight` is `alarm`, and the // three newer roles fall back (accent and good to foreground, caution to subdued). // // Pick the theme at build time: `--dart-define=YUKART_THEME=default` (default: rei, smart-serow's). import 'dart:convert'; import 'dart:math' show max, min; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// The HUD face (smart-serow's DIN 1451). Opt-in: a widget names it in its own /// TextStyle; everything else keeps the basic font, which covers CJK where DIN does not. const hudFont = 'DIN1451'; const themeName = String.fromEnvironment('YUKART_THEME', defaultValue: 'rei'); /// One variant (dark or bright) of a theme. class Palette { const Palette({ required this.background, required this.foreground, required this.subdued, required this.accent, required this.good, required this.caution, required this.alarm, }); /// Behind everything. final Color background; /// Values and primary text: what is read at a glance. final Color foreground; /// Labels, units, grids, secondary text, and anything missing or stale. final Color subdued; /// Emphasis without danger: buttons, links, the current item. final Color accent; /// Connected, done, healthy. final Color good; /// In progress, unconfirmed, mock data. final Color caution; /// Down, refused, destructive, over a limit: the one thing that must be seen. final Color alarm; /// Raised bars and panels: background with a little foreground mixed in. Color get surface => Color.lerp(background, foreground, 0.08)!; /// Text drawn on a filled [fill]: background or foreground, whichever contrasts more. Color onFill(Color fill) { double contrast(Color a, Color b) { final (x, y) = (a.computeLuminance(), b.computeLuminance()); return (max(x, y) + 0.05) / (min(x, y) + 0.05); } return contrast(fill, background) >= contrast(fill, foreground) ? background : foreground; } static Palette _fromJson(Map j) { Color? c(String k) => j[k] is String ? Color( 0xFF000000 | int.parse((j[k] as String).substring(1), radix: 16), ) : null; final foreground = c('foreground')!; final subdued = c('subdued')!; return Palette( background: c('background')!, foreground: foreground, subdued: subdued, accent: c('accent') ?? foreground, good: c('good') ?? foreground, caution: c('caution') ?? subdued, // smart-serow calls it highlight. alarm: (c('alarm') ?? c('highlight'))!, ); } /// The last resort, used when no theme file loads (smart-serow's four, plus three). static const fallbackDark = Palette( background: Color(0xFF000000), foreground: Color(0xFFFFFFFF), subdued: Color(0xFF808080), accent: Color(0xFF5EC8F2), good: Color(0xFF6CCB5F), caution: Color(0xFFFFC107), alarm: Color(0xFFFF5555), ); static const fallbackBright = Palette( background: Color(0xFFF0F0F0), foreground: Color(0xFF1A1A1A), subdued: Color(0xFF606060), accent: Color(0xFF0277BD), good: Color(0xFF2E7D32), caution: Color(0xFF8D6E00), alarm: Color(0xFFCC0000), ); } /// A theme: both variants, and which one is showing. Listen to it to follow a switch. class Themes extends ChangeNotifier { Themes(this.name, this.dark, this.bright); final String name; final Palette dark; final Palette bright; bool _isDark = true; bool get isDark => _isDark; Palette get current => _isDark ? dark : bright; set isDark(bool v) { if (v == _isDark) return; _isDark = v; notifyListeners(); } void toggle() => isDark = !_isDark; /// [themeName], falling back to `default`, then to the hard-coded palette /// (smart-serow's chain). static Future load([String name = themeName]) async { for (final n in {name, 'default'}) { try { final j = jsonDecode( await rootBundle.loadString('assets/themes/$n.json'), ) as Map; return Themes( n, Palette._fromJson(j['dark'] as Map), Palette._fromJson(j['bright'] as Map), ); } catch (_) { // Missing or malformed: try the next one. } } return Themes('fallback', Palette.fallbackDark, Palette.fallbackBright); } /// Material's theme for the rest of the app, from the same roles: buttons and /// selection in accent, errors in alarm, raised surfaces in surface. ThemeData material() { final p = current; final brightness = _isDark ? Brightness.dark : Brightness.light; return ThemeData( useMaterial3: true, brightness: brightness, scaffoldBackgroundColor: p.background, colorScheme: ColorScheme.fromSeed( seedColor: p.accent, brightness: brightness, ).copyWith( surface: p.background, onSurface: p.foreground, surfaceContainerHigh: p.surface, surfaceContainerHighest: p.surface, primary: p.accent, onPrimary: p.onFill(p.accent), primaryContainer: Color.lerp(p.background, p.accent, 0.25), onPrimaryContainer: p.foreground, secondary: p.accent, error: p.alarm, onError: p.onFill(p.alarm), outline: p.subdued, onSurfaceVariant: p.subdued, ), ); } } /// The current palette, for any widget below it: `AppTheme.of(context).foreground`. class AppTheme extends InheritedWidget { const AppTheme({super.key, required this.palette, required super.child}); final Palette palette; static Palette of(BuildContext context) { final t = context.dependOnInheritedWidgetOfExactType(); assert(t != null, 'No AppTheme above this widget'); return t?.palette ?? Palette.fallbackDark; } @override bool updateShouldNotify(AppTheme old) => palette != old.palette; }