31a844bb94
Without a light sensor an app may read (the Redmi Pad SE has none), auto is dark while the sun is more than 4° below the horizon (lib/theme/sun.dart), at Android's coarse location, polled and kept across restarts. The tablet's own, not the Orin's GNSS: the tablet has its own GNSS and cellular. Android's dark theme is the last fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
11 KiB
Dart
362 lines
11 KiB
Dart
// 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<String, dynamic> 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 tablet's last location ([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<double?>(null);
|
|
|
|
/// Follow a light sensor in auto. Errors (no sensor, or refused) leave auto to the sun.
|
|
void followLight(Stream<double> 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 tablet's location, 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<Themes> 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<String, dynamic>;
|
|
return Themes(
|
|
n,
|
|
Palette._fromJson(j['dark'] as Map<String, dynamic>),
|
|
Palette._fromJson(j['bright'] as Map<String, dynamic>),
|
|
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<AppTheme>();
|
|
assert(t != null, 'No AppTheme above this widget');
|
|
return t?.palette ?? Palette.fallbackDark;
|
|
}
|
|
|
|
@override
|
|
bool updateShouldNotify(AppTheme old) => palette != old.palette;
|
|
}
|