// 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:async'; 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'; import 'light.dart'; import 'sun.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: auto (a light sensor, else the sun, else Android's dark theme), /// 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. /// In auto it follows a light sensor ([followLight]) where the device lets an app read /// one; else the sun at the car's last position ([setPosition]); else Android's dark /// theme (by hand or on its schedule). The mode and the position are kept across restarts. class Themes extends ChangeNotifier with WidgetsBindingObserver { Themes( this.name, this.dark, this.bright, [ this._prefs, this._clock = DateTime.now, ]) { _mode = VariantMode.values.asNameMap()[_prefs?.getString(_key)] ?? VariantMode.auto; final (lat, lon) = (_prefs?.getDouble(_latKey), _prefs?.getDouble(_lonKey)); if (lat != null && lon != null) _position = (lat, lon); _sunDark = _sunNow(); _sunTimer = Timer.periodic(const Duration(minutes: 1), (_) => _sunCheck()); WidgetsBinding.instance.addObserver(this); } static const _key = 'variant_mode'; static const _latKey = 'sun_lat'; static const _lonKey = 'sun_lon'; final DateTime Function() _clock; 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(); } final _light = LightSwitch(); Timer? _holdTimer; /// The last light level, in lux, while a sensor reports (for Settings, to tune by). final lux = ValueNotifier(null); /// Follow a light sensor in auto. Errors (no sensor, or refused) leave auto to the sun. void followLight(Stream levels) { void changed(bool? before) { if (_light.isDark != before && _mode == VariantMode.auto) { notifyListeners(); } } void feed(double l) { final before = _light.isDark; _light.feed(l, DateTime.now()); changed(before); // A sensor may report only on change: finish a hold even if none comes. _holdTimer?.cancel(); if (_light.pending) { _holdTimer = Timer(LightSwitch.hold, () { final before = _light.isDark; _light.settle(); changed(before); }); } } levels.listen( (l) { lux.value = l; feed(l); }, onError: (_) => lux.value = null, cancelOnError: true, ); } (double, double)? _position; bool? _sunDark; late final Timer _sunTimer; /// The car's position, for the sun. Kept (to 0.1°, about 10 km: plenty for the sun) /// so auto knows the sun before the first fix. void setPosition(double lat, double lon) { double r(double x) => (x * 10).round() / 10; final p = (r(lat), r(lon)); if (p == _position) return; _position = p; _prefs?.setDouble(_latKey, p.$1); _prefs?.setDouble(_lonKey, p.$2); _sunCheck(); } /// The sun's elevation now, in degrees, or null with no position yet. double? get sunElevationNow { final p = _position; return p == null ? null : sunElevation(p.$1, p.$2, _clock()); } bool? _sunNow() { final e = sunElevationNow; return e == null ? null : e < darkBelowDeg; } void _sunCheck() { final now = _sunNow(); if (now == _sunDark) return; _sunDark = now; if (_mode == VariantMode.auto && _light.isDark == null) notifyListeners(); } /// What auto follows now, in words, for Settings. String get autoSource => _light.isDark != null ? 'the light sensor' : _sunDark != null ? 'the sun' : "Android's dark theme"; bool get isDark => switch (_mode) { VariantMode.auto => _light.isDark ?? _sunDark ?? 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 && _light.isDark == null && _sunDark == null) { notifyListeners(); } } @override void dispose() { _sunTimer.cancel(); WidgetsBinding.instance.removeObserver(this); _holdTimer?.cancel(); lux.dispose(); 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; }