// 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'; import 'package:shared_preferences/shared_preferences.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), ); } /// Which variant shows: Android's own dark theme (auto), or one held fixed. enum VariantMode { auto, dark, bright } /// A theme: both variants, and which one is showing. Listen to it to follow a switch, /// and, in auto, Android's dark theme turning on or off (by hand or on its schedule). /// The mode is kept across restarts. class Themes extends ChangeNotifier with WidgetsBindingObserver { Themes(this.name, this.dark, this.bright, [this._prefs]) { _mode = VariantMode.values.asNameMap()[_prefs?.getString(_key)] ?? VariantMode.auto; WidgetsBinding.instance.addObserver(this); } static const _key = 'variant_mode'; final String name; final Palette dark; final Palette bright; final SharedPreferences? _prefs; late VariantMode _mode; VariantMode get mode => _mode; set mode(VariantMode v) { if (v == _mode) return; _mode = v; _prefs?.setString(_key, v.name); notifyListeners(); } bool get isDark => switch (_mode) { VariantMode.auto => WidgetsBinding.instance.platformDispatcher.platformBrightness == Brightness.dark, VariantMode.dark => true, VariantMode.bright => false, }; Palette get current => isDark ? dark : bright; /// The quick switch: hold the other variant fixed. void toggle() => mode = isDark ? VariantMode.bright : VariantMode.dark; @override void didChangePlatformBrightness() { if (_mode == VariantMode.auto) notifyListeners(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } /// [themeName], falling back to `default`, then to the hard-coded palette /// (smart-serow's chain). static Future load([String name = themeName]) async { SharedPreferences? prefs; try { prefs = await SharedPreferences.getInstance(); } catch (_) { // No storage: the mode starts at auto each time. } 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), prefs, ); } catch (_) { // Missing or malformed: try the next one. } } return Themes( 'fallback', Palette.fallbackDark, Palette.fallbackBright, prefs, ); } /// 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; }