diff --git a/assets/themes/default.json b/assets/themes/default.json index 5b52a37..33d414e 100644 --- a/assets/themes/default.json +++ b/assets/themes/default.json @@ -2,13 +2,19 @@ "dark": { "background": "#000000", "foreground": "#FFFFFF", - "highlight": "#FF5555", - "subdued": "#808080" + "subdued": "#808080", + "accent": "#5EC8F2", + "good": "#6CCB5F", + "caution": "#FFC107", + "alarm": "#FF5555" }, "bright": { "background": "#F0F0F0", "foreground": "#1A1A1A", - "highlight": "#CC0000", - "subdued": "#606060" + "subdued": "#606060", + "accent": "#0277BD", + "good": "#2E7D32", + "caution": "#8D6E00", + "alarm": "#CC0000" } } diff --git a/assets/themes/rei.json b/assets/themes/rei.json index 74908bd..7c2b28e 100644 --- a/assets/themes/rei.json +++ b/assets/themes/rei.json @@ -2,13 +2,19 @@ "dark": { "background": "#303030", "foreground": "#EAEAEA", - "highlight": "#FA1504", - "subdued": "#fda052" + "subdued": "#fda052", + "accent": "#5EC8F2", + "good": "#7CC46A", + "caution": "#FFD54F", + "alarm": "#FA1504" }, "bright": { "background": "#fda052", "foreground": "#303030", - "highlight": "#df2100", - "subdued": "#EAEAEA" + "subdued": "#EAEAEA", + "accent": "#0D47A1", + "good": "#1B5E20", + "caution": "#5D4000", + "alarm": "#df2100" } } diff --git a/lib/README.md b/lib/README.md index deb246a..7a24d1f 100644 --- a/lib/README.md +++ b/lib/README.md @@ -17,7 +17,7 @@ Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from. | Widget | File | Shows | From | |---|---|---|---| -| `StatBox`, `StatBoxMain` | `widgets/stat_box.dart` | A value, its unit and label, sized from its box's height; highlight on warning, subdued when dim | smart-serow | +| `StatBox`, `StatBoxMain` | `widgets/stat_box.dart` | A value, its unit and label, sized from its box's height; alarm on warning, subdued when dim | smart-serow | | `Telltale` | `widgets/telltale.dart` | An on/off lamp: a label or an icon, lit or dark, "—" when missing | Yukart | | `WhiskeyMark` | `widgets/whiskey_mark.dart` | Attitude: a horizon tilting with roll, ladder moving with pitch | smart-serow | | `AccelGraph` | `widgets/accel_graph.dart` | G-meter: a dot for lateral/longitudinal g, a trace and a peak marker over a window | smart-serow | @@ -27,8 +27,11 @@ Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from. - **The boxed pattern.** A widget takes its share of a row and sizes its type from its own height, so a layout is a grid of boxes and a value gaining a digit never moves anything. Labels shrink to fit, never wrap. -- **Colours by meaning.** `AppTheme.of(context)` gives the palette's `background`, `foreground`, `highlight` and - `subdued`; no widget names a colour. Themes are smart-serow's JSON (`assets/themes/`), `rei` by default. +- **Colours by meaning.** `AppTheme.of(context)` gives seven roles: `background`, `foreground`, + `subdued` (muted, missing, stale), `accent` (emphasis without danger: buttons, links, the current item), + and the state gradient `good` → `caution` → `alarm`; plus the derived `surface` (raised bars) and + `onFill(colour)` (text on a fill). No widget names a colour. Themes are JSON in `assets/themes/`, `rei` by + default; smart-serow's four-key files still load (their `highlight` is `alarm`). - **Cosmetic fonts are opt-in.** A widget that wants the HUD face names `fontFamily: hudFont` in its own `TextStyle`; everything else keeps the basic font, which covers CJK where DIN 1451 does not. - **Missing is a state** (app guideline 2): a widget given no value draws "—" or subdued, never an error. diff --git a/lib/camview.dart b/lib/camview.dart index 369f643..b735d86 100644 --- a/lib/camview.dart +++ b/lib/camview.dart @@ -10,6 +10,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'theme/palette.dart'; + const camviewPort = 7710; class CamView extends ChangeNotifier { @@ -165,7 +167,7 @@ class _CamViewPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.black, + backgroundColor: AppTheme.of(context).background, appBar: AppBar(title: const Text('Camera (test)')), body: SafeArea( child: ListenableBuilder( @@ -182,7 +184,7 @@ class _CamViewPageState extends State { cam.available == false ? 'Camera unavailable on the Orin' : cam.status, - style: const TextStyle(color: Colors.white70), + style: TextStyle(color: AppTheme.of(context).subdued), ), ) : Image.memory( diff --git a/lib/checklist.dart b/lib/checklist.dart index cb91410..04f5e2f 100644 --- a/lib/checklist.dart +++ b/lib/checklist.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'boundary.dart'; +import 'theme/palette.dart'; const checklistAssets = 'assets/contracts/checklists/'; @@ -309,14 +310,17 @@ class _ChecklistPageState extends State { Widget _panel(BuildContext context) { final s = run.current; final theme = Theme.of(context); + final p = AppTheme.of(context); final a = run.currentAction; + // The big button by what pressing it does: start (good), stop a running span + // (caution: something is in progress), a point mark (accent), skip on (subdued). final (Color color, IconData icon) = run.stepDone - ? (Colors.blueGrey, Icons.skip_next) + ? (p.subdued, Icons.skip_next) : a!.point - ? (Colors.blue, Icons.flag) + ? (p.accent, Icons.flag) : run.inSpan - ? (Colors.red.shade700, Icons.stop) - : (Colors.green.shade700, Icons.play_arrow); + ? (p.caution, Icons.stop) + : (p.good, Icons.play_arrow); return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -339,7 +343,7 @@ class _ChecklistPageState extends State { child: FilledButton( style: FilledButton.styleFrom( backgroundColor: color, - foregroundColor: Colors.white, + foregroundColor: p.onFill(color), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), ), onPressed: run.finished ? null : () => _send(run.press()), @@ -394,7 +398,7 @@ class _ChecklistPageState extends State { style: TextStyle( fontSize: 32, fontFeatures: const [FontFeature.tabularFigures()], - color: reached ? Colors.lightGreenAccent : Colors.white, + color: reached ? AppTheme.of(context).good : AppTheme.of(context).foreground, ), ); } @@ -409,9 +413,9 @@ class _ChecklistPageState extends State { final pending = b.pending.length; final ack = b.acks.where((x) => x.id == last.id).firstOrNull; final (String status, Color? color) = ack != null - ? (ack.ok ? ('✓', null) : ('✗ ${ack.detail}', Colors.redAccent)) + ? (ack.ok ? ('✓', AppTheme.of(context).good) : ('✗ ${ack.detail}', AppTheme.of(context).alarm)) : b.pending.any((p) => p.id == last.id) - ? ('… unacked', Colors.orange) + ? ('… unacked', AppTheme.of(context).caution) : ('?', null); return Text( '"${last.text}" $status${pending > 1 ? ' · $pending waiting' : ''}', diff --git a/lib/dash.dart b/lib/dash.dart index f5acf26..344c4e0 100644 --- a/lib/dash.dart +++ b/lib/dash.dart @@ -49,13 +49,13 @@ class DashPage extends StatelessWidget { children: [ if (b.mock) Container( - color: p.highlight, + color: p.caution, padding: const EdgeInsets.symmetric(vertical: 4), child: Text( 'MOCK DATA — NOT FROM THE CAR', textAlign: TextAlign.center, style: TextStyle( - color: p.background, + color: p.onFill(p.caution), fontWeight: FontWeight.w900, letterSpacing: 2, ), @@ -186,8 +186,8 @@ class _DashBar extends StatelessWidget { final p = AppTheme.of(context); final (text, color) = switch (boundary.state) { LinkState.up => ('LINK', p.subdued), - LinkState.connecting => ('CONNECTING', p.highlight), - LinkState.down => ('NO LINK', p.highlight), + LinkState.connecting => ('CONNECTING', p.caution), + LinkState.down => ('NO LINK', p.alarm), }; return SizedBox( height: 40, diff --git a/lib/main.dart b/lib/main.dart index 4b7ff34..99583a3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -169,14 +169,15 @@ class MockBanner extends StatelessWidget { @override Widget build(BuildContext context) { + final p = AppTheme.of(context); return Container( - color: Colors.amber.shade700, + color: p.caution, padding: const EdgeInsets.symmetric(vertical: 6), - child: const Text( + child: Text( 'MOCK DATA — NOT FROM THE CAR', textAlign: TextAlign.center, style: TextStyle( - color: Colors.black, + color: p.onFill(p.caution), fontWeight: FontWeight.w900, fontSize: 20, letterSpacing: 2, @@ -200,10 +201,11 @@ class LinkBar extends StatelessWidget { @override Widget build(BuildContext context) { final b = boundary; + final p = AppTheme.of(context); final (color, label) = switch (b.state) { - LinkState.up => (Colors.green, 'CONNECTED'), - LinkState.connecting => (Colors.orange, 'CONNECTING'), - LinkState.down => (Colors.red, 'DOWN'), + LinkState.up => (p.good, 'CONNECTED'), + LinkState.connecting => (p.caution, 'CONNECTING'), + LinkState.down => (p.alarm, 'DOWN'), }; // Fixed-width numbers, padded with figure spaces (U+2007, digit-wide): the bar must // not reflow every time a value gains a digit. @@ -230,7 +232,10 @@ class LinkBar extends StatelessWidget { crossAxisAlignment: WrapCrossAlignment.center, children: [ Chip( - label: Text(label), + label: Text( + label, + style: TextStyle(color: color, fontWeight: FontWeight.w600), + ), backgroundColor: color.withValues(alpha: 0.3), ), Text(b.url, style: const TextStyle(fontFamily: 'monospace')), @@ -317,7 +322,10 @@ Future orinPowerOptions(BuildContext context, Boundary b) async { label: const Text('Reboot'), ), FilledButton.icon( - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + style: FilledButton.styleFrom( + backgroundColor: AppTheme.of(context).alarm, + foregroundColor: AppTheme.of(context).onFill(AppTheme.of(context).alarm), + ), onPressed: () => Navigator.of(context).pop('poweroff'), icon: const Icon(Icons.power_settings_new), label: const Text('Power off'), @@ -403,26 +411,27 @@ class SignalTile extends StatelessWidget { @override Widget build(BuildContext context) { + final p = AppTheme.of(context); if (missing) { return ListTile( title: Text( name, - style: const TextStyle(fontFamily: 'monospace', color: Colors.grey), + style: TextStyle(fontFamily: 'monospace', color: p.subdued), ), - subtitle: const Text( + subtitle: Text( 'MISSING · not in the latest state', - style: TextStyle(color: Colors.redAccent), + style: TextStyle(color: p.alarm), ), trailing: Text.rich( TextSpan( children: [ - const TextSpan( + TextSpan( text: '?', - style: TextStyle(fontSize: 34, color: Colors.grey), + style: TextStyle(fontSize: 34, color: p.subdued), ), TextSpan( text: ' ${envelope['unit'] ?? ''}', - style: const TextStyle(fontSize: 16, color: Colors.grey), + style: TextStyle(fontSize: 16, color: p.subdued), ), ], ), @@ -439,12 +448,13 @@ class SignalTile extends StatelessWidget { fontSize: 34, fontWeight: FontWeight.w600, fontFeatures: const [FontFeature.tabularFigures()], - color: stale || v == null ? Colors.grey : null, + color: stale || v == null ? p.subdued : p.foreground, fontStyle: stale ? FontStyle.italic : null, ); return ListTile( title: Text(name, style: const TextStyle(fontFamily: 'monospace')), subtitle: Text( + style: stale ? TextStyle(color: p.caution) : null, [ if (stale) quality.toUpperCase(), if (v == null) 'no value', @@ -479,11 +489,14 @@ class RawLog extends StatelessWidget { if (b.pending.isNotEmpty || b.acks.isNotEmpty) ...[ Text('Commands', style: Theme.of(context).textTheme.titleSmall), for (final p in b.pending) - Text('… ${p.cmd} ${p.args['text'] ?? ''} (unacked)'), + Text( + '… ${p.cmd} ${p.args['text'] ?? ''} (unacked)', + style: TextStyle(color: AppTheme.of(context).caution), + ), for (final a in b.acks) Text( '${a.ok ? '✓' : '✗'} ${a.detail}', - style: TextStyle(color: a.ok ? null : Colors.redAccent), + style: TextStyle(color: a.ok ? AppTheme.of(context).good : AppTheme.of(context).alarm), ), const Divider(), ], diff --git a/lib/theme/palette.dart b/lib/theme/palette.dart index 638226a..bafd208 100644 --- a/lib/theme/palette.dart +++ b/lib/theme/palette.dart @@ -1,14 +1,22 @@ -// The palette system, from smart-serow's HUD: four semantic colours, each with a dark -// (low ambient light) and a bright (high ambient light) variant, read from a theme -// JSON in assets/themes/. Same file format as smart-serow's extra/themes/, so themes -// move between the two as they are. Widgets ask for meanings, never for colours: +// 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'; @@ -24,45 +32,87 @@ class Palette { const Palette({ required this.background, required this.foreground, - required this.highlight, required this.subdued, + required this.accent, + required this.good, + required this.caution, + required this.alarm, }); /// Behind everything. final Color background; - /// Values: what is read at a glance. + /// Values and primary text: what is read at a glance. final Color foreground; - /// Warnings, and the one thing that must be seen. - final Color highlight; - - /// Labels, units, grids, and anything missing or stale. + /// 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) => - Color(0xFF000000 | int.parse((j[k] as String).substring(1), radix: 16)); + 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: c('foreground'), - highlight: c('highlight'), - subdued: c('subdued'), + 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'))!, ); } - /// smart-serow's hard-coded last resort, used when no theme file loads. + /// 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), - highlight: Color(0xFFFF5555), 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), - highlight: Color(0xFFCC0000), subdued: Color(0xFF606060), + accent: Color(0xFF0277BD), + good: Color(0xFF2E7D32), + caution: Color(0xFF8D6E00), + alarm: Color(0xFFCC0000), ); } @@ -106,7 +156,8 @@ class Themes extends ChangeNotifier { return Themes('fallback', Palette.fallbackDark, Palette.fallbackBright); } - /// Material's theme for the rest of the app, from the same four colours. + /// 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; @@ -116,13 +167,20 @@ class Themes extends ChangeNotifier { scaffoldBackgroundColor: p.background, colorScheme: ColorScheme.fromSeed( - seedColor: p.highlight, + seedColor: p.accent, brightness: brightness, ).copyWith( surface: p.background, onSurface: p.foreground, - primary: p.highlight, - error: p.highlight, + 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, ), diff --git a/lib/widgets/gps_compass.dart b/lib/widgets/gps_compass.dart index 3259944..3500256 100644 --- a/lib/widgets/gps_compass.dart +++ b/lib/widgets/gps_compass.dart @@ -50,7 +50,8 @@ class GpsCompass extends StatelessWidget { final theme = AppTheme.of(context); // No signal = subdued color, valid = foreground - final iconColour = _hasSignal ? theme.foreground : theme.highlight; + // Heading: foreground; acquiring: caution; no receiver at all: alarm. + final iconColour = _hasSignal ? theme.foreground : (_isAcquiring ? theme.caution : theme.alarm); // Convert to radians, 0 = no rotation when no signal final angle = _hasSignal ? (heading! * math.pi / 180.0) : 0.0; diff --git a/lib/widgets/stat_box.dart b/lib/widgets/stat_box.dart index aaad93e..ae66624 100644 --- a/lib/widgets/stat_box.dart +++ b/lib/widgets/stat_box.dart @@ -2,7 +2,7 @@ // every readout is a box that takes its share of a row (`flex`) and sizes its type // from its own height, so a layout is a grid of boxes, and a value changing digits // never moves anything. Value in foreground with tabular figures, unit and label in -// subdued; a warning turns the value to highlight. +// subdued; a warning turns the value to alarm. // // Added for Yukart: [dim]. A missing or stale value is a normal state in a car // (app guideline 2), shown in subdued rather than hidden. @@ -28,7 +28,7 @@ class StatBox extends StatelessWidget { final String label; final int flex; - /// If it returns true, the value shows in highlight. + /// If it returns true, the value shows in alarm. final bool Function()? isWarning; /// Missing or stale: the value shows in subdued. @@ -41,7 +41,7 @@ class StatBox extends StatelessWidget { final valueColor = dim ? theme.subdued : warning - ? theme.highlight + ? theme.alarm : theme.foreground; return Expanded( @@ -168,7 +168,7 @@ class StatBoxMain extends StatelessWidget { color: dim ? theme.subdued : warning - ? theme.highlight + ? theme.alarm : theme.foreground, height: 1, ), diff --git a/lib/widgets/telltale.dart b/lib/widgets/telltale.dart index 6c71816..c71f65f 100644 --- a/lib/widgets/telltale.dart +++ b/lib/widgets/telltale.dart @@ -7,7 +7,7 @@ import '../reading.dart'; import '../theme/palette.dart'; /// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground -/// (highlight when [warn]), dark in subdued; missing shows as a dash. +/// (alarm when [warn]), dark in subdued; missing shows as a dash. class Telltale extends StatelessWidget { const Telltale({ super.key, @@ -32,7 +32,7 @@ class Telltale extends StatelessWidget { final missing = reading.value == null; final lit = reading.on && !reading.dim; final color = lit - ? (warn ? p.highlight : p.foreground) + ? (warn ? p.alarm : p.foreground) : p.subdued.withValues(alpha: missing ? 0.35 : 0.6); return Expanded( flex: flex,