diff --git a/docs/impl/plans/DASH-NEXT.md b/docs/impl/plans/DASH-NEXT.md index f4ab0de..a184295 100644 --- a/docs/impl/plans/DASH-NEXT.md +++ b/docs/impl/plans/DASH-NEXT.md @@ -12,6 +12,14 @@ what the Orin publishes. ## 1. Orin status in a top banner, like a phone's +**First cut (2026-10-15):** `widgets/status_bar.dart`, drawn by the shell over every view (the +Dash's own top line is gone; its menu button moved in). Left: the menu (Dash only) and the +link. Right: Orin temperature (only from 80 °C), CAN (alarm when absent or bus-off), OBD +(only while the ELM is connected), GNSS fix icon and satellite count, LTE bars and +technology (only with a modem), WiFi bars, UPS battery and % (caution on battery, alarm +under 20 %), then the dark/bright button. Tapping the strip does nothing yet. +What was asked: + A thin strip across the top of the app (the Dash's own top line included) showing the Orin the way a phone's status bar shows the phone: its network links and its power. @@ -65,8 +73,8 @@ chosen readouts. **Done (2026-10-15):** Settings has Auto / Dark / Bright (a segmented control, kept across restarts). Auto follows Android's dark theme (`platformBrightness`, so its schedule too). The -Dash's button holds the other variant, and a long press returns it to auto. Not done: the -ambient light sensor as an auto source, and moving the button into the banner (item 1). +status bar's button (it moved there with item 1) holds the other variant, and a long press +returns it to auto. Not done: the light sensor as an auto source. What was asked: Today the variant is a switch (the Dash's top-right button, and Settings). Wanted: diff --git a/lib/README.md b/lib/README.md index 1eee7bd..7da8bdb 100644 --- a/lib/README.md +++ b/lib/README.md @@ -29,6 +29,7 @@ pirate-station-aide's (main views, then the rarely used ones grouped as Tools). | `AccelGraph` | `widgets/accel_graph.dart` | G-meter: a dot for lateral/longitudinal g, a trace and a peak marker over a window | smart-serow | | `GpsCompass` | `widgets/gps_compass.dart` | Heading arrow and bearing; "ACQ" while acquiring, "N/A" without | smart-serow, from gps-speedo | | `LinkChip`, `LinkDetails` | `widgets/link_status.dart` | The link's state in good / caution / alarm; address, last-message age, clock offset, USB link | Yukart | +| `StatusBar` | `widgets/status_bar.dart` | The Orin like a phone's status bar, over every view: link, then hot temperature, CAN, OBD (while connected), GNSS fix and satellites, LTE, WiFi, UPS; and the dark/bright button | Yukart | | `MockBanner` | `widgets/mock_banner.dart` | "MOCK DATA — NOT FROM THE CAR" in caution, over every page while the gateway is mocking | Yukart | ## Conventions @@ -41,7 +42,7 @@ pirate-station-aide's (main views, then the rarely used ones grouped as Tools). `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`). - **Dark or bright.** `Themes.mode` is `auto` (Android's dark theme, schedule included), `dark` or - `bright`, chosen in Settings and kept across restarts. The Dash's quick button holds the other variant; + `bright`, chosen in Settings and kept across restarts. The status bar's button holds the other variant; a long press there is auto again. - **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. diff --git a/lib/pages/dash.dart b/lib/pages/dash.dart index 94a28fc..beb2082 100644 --- a/lib/pages/dash.dart +++ b/lib/pages/dash.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import '../link/boundary.dart'; -import '../shell_scope.dart'; import '../link/reading.dart'; import '../theme/palette.dart'; import '../widgets/accel_graph.dart'; @@ -16,14 +15,13 @@ import '../widgets/telltale.dart'; import '../widgets/whiskey_mark.dart'; class DashPage extends StatelessWidget { - const DashPage({super.key, required this.boundary, required this.themes}); + const DashPage({super.key, required this.boundary}); final Boundary boundary; - final Themes themes; @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: Listenable.merge([boundary, themes]), + listenable: boundary, builder: (context, _) { final p = AppTheme.of(context); final b = boundary; @@ -50,7 +48,6 @@ class DashPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _DashBar(boundary: b, themes: themes), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), @@ -163,44 +160,3 @@ class DashPage extends StatelessWidget { ); } } - -/// The dash's own thin top line: the menu, link state, and the dark/bright quick switch. -class _DashBar extends StatelessWidget { - const _DashBar({required this.boundary, required this.themes}); - final Boundary boundary; - final Themes themes; - - @override - Widget build(BuildContext context) { - final p = AppTheme.of(context); - final (text, color) = switch (boundary.state) { - LinkState.up => ('LINK', p.subdued), - LinkState.connecting => ('CONNECTING', p.caution), - LinkState.down => ('NO LINK', p.alarm), - }; - return SizedBox( - height: 40, - child: Row( - children: [ - MenuButton(color: p.subdued), - Text(text, style: TextStyle(color: color, letterSpacing: 2)), - const Spacer(), - // Tap: hold the other variant. Long press: back to auto (Settings has both). - GestureDetector( - onLongPress: () => themes.mode = VariantMode.auto, - child: IconButton( - tooltip: themes.isDark ? 'Bright' : 'Dark', - onPressed: themes.toggle, - icon: Icon( - themes.mode == VariantMode.auto - ? Icons.brightness_auto - : (themes.isDark ? Icons.light_mode : Icons.dark_mode), - color: p.subdued, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/shell.dart b/lib/shell.dart index 5917eba..b0d0897 100644 --- a/lib/shell.dart +++ b/lib/shell.dart @@ -20,6 +20,7 @@ import 'shell_scope.dart'; import 'theme/palette.dart'; import 'widgets/link_status.dart'; import 'widgets/mock_banner.dart'; +import 'widgets/status_bar.dart'; class AppShell extends StatefulWidget { const AppShell({ @@ -53,7 +54,7 @@ class _AppShellState extends State { Widget _page(ShellView v) { final b = widget.boundary; return switch (v) { - ShellView.dash => DashPage(boundary: b, themes: widget.themes), + ShellView.dash => DashPage(boundary: b), ShellView.signals => SignalsPage(boundary: b, usb: widget.usb), ShellView.captures => Navigator( key: _captures, @@ -113,14 +114,30 @@ class _AppShellState extends State { }, ), body: ListenableBuilder( - listenable: widget.boundary, + listenable: Listenable.merge([widget.boundary, widget.themes]), builder: (context, child) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.boundary.mock) - const SafeArea(bottom: false, child: MockBanner()), - // Pages draw their own top (app bars); keep every bottom clear of the system bar. - Expanded(child: SafeArea(top: false, child: child!)), + // The Orin's status over every view; the Dash, with no app bar, gets the menu in it. + SafeArea( + bottom: false, + child: StatusBar( + boundary: widget.boundary, + themes: widget.themes, + leading: current == ShellView.dash + ? MenuButton(color: AppTheme.of(context).subdued) + : null, + ), + ), + if (widget.boundary.mock) const MockBanner(), + // The status bar took the top inset; keep every bottom clear of the system bar. + Expanded( + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: SafeArea(top: false, child: child!), + ), + ), ], ), child: IndexedStack( diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart new file mode 100644 index 0000000..b51dae2 --- /dev/null +++ b/lib/widgets/status_bar.dart @@ -0,0 +1,232 @@ +// The Orin at a glance, the way a phone's status bar shows the phone: the link on the +// left, then its links out and its power on the right, and the dark/bright switch. +// Drawn by the shell above every view (DASH-NEXT.md item 1). +// +// An indicator shows its state in palette roles: foreground when fine, caution when +// degraded or on battery, alarm when down, subdued when stale or unknown. Things +// that are only news when wrong (temperature) appear only then; ones that are only +// news when present (the ELM, a modem) appear only then. + +import 'package:flutter/material.dart'; + +import '../link/boundary.dart'; +import '../link/reading.dart'; +import '../theme/palette.dart'; + +class StatusBar extends StatelessWidget { + const StatusBar({ + super.key, + required this.boundary, + required this.themes, + this.leading, + }); + final Boundary boundary; + final Themes themes; + + /// Before the link state: the menu button, on a view with no app bar of its own. + final Widget? leading; + + static const height = 32.0; + + @override + Widget build(BuildContext context) { + final p = AppTheme.of(context); + final b = boundary; + Reading r(String n) => Reading.of(b, n); + final up = b.state == LinkState.up; + final (text, color) = switch (b.state) { + LinkState.up => ('LINK', p.subdued), + LinkState.connecting => ('CONNECTING', p.caution), + LinkState.down => ('NO LINK', p.alarm), + }; + return SizedBox( + height: height, + child: IconTheme( + data: IconThemeData(size: 18, color: p.foreground), + child: DefaultTextStyle( + style: TextStyle( + fontSize: 14, + color: p.foreground, + fontFeatures: const [FontFeature.tabularFigures()], + ), + child: Row( + children: [ + if (leading != null) leading! else const SizedBox(width: 16), + Text(text, style: TextStyle(color: color, letterSpacing: 2)), + const Spacer(), + // Without the link, every Orin value is old news. + if (up) ...[ + ..._temperature(p, r('orin_tj_temp_c')), + ..._can(p, r('can_up'), r('can_bus_state')), + ..._elm(p, r('elm_connected')), + ..._gnss(p, r('gnss_fix'), r('gnss_satellites')), + ..._lte( + p, + r('net_lte_state'), + r('net_lte_tech'), + r('net_lte_signal_pct'), + ), + ..._wifi(p, r('net_wifi_up'), r('net_wifi_signal_dbm')), + ..._ups(p, r('ups_state'), r('ups_battery_pct')), + ], + _VariantButton(themes: themes), + ], + ), + ), + ), + ); + } + + /// One indicator: an icon, maybe a short text, and the gap after it. + static List _item(Color c, {IconData? icon, String? text}) => [ + if (icon != null) Icon(icon, color: c), + if (icon != null && text != null) const SizedBox(width: 2), + if (text != null) Text(text, style: TextStyle(color: c)), + const SizedBox(width: 14), + ]; + + /// Only when hot: caution from 80 °C, alarm from 90 (the Orin throttles near 99). + static List _temperature(Palette p, Reading t) { + final c = t.number; + if (t.dim || c == null || c < 80) return []; + return _item( + c >= 90 ? p.alarm : p.caution, + icon: Icons.thermostat, + text: '${c.round()}°', + ); + } + + static List _can(Palette p, Reading up, Reading state) { + if (up.value == null) return _item(p.subdued, text: 'CAN'); + final color = switch ((up.on, state.value)) { + (false, _) || (_, 'BUS-OFF') || (_, 'STOPPED') => p.alarm, + (_, 'ERROR-WARNING') || (_, 'ERROR-PASSIVE') => p.caution, + _ => p.foreground, + }; + return _item(up.dim ? p.subdued : color, text: 'CAN'); + } + + /// The OBD-II adapter, only while connected (it is for calibration, not always on). + static List _elm(Palette p, Reading c) => c.on + ? _item( + c.dim ? p.subdued : p.foreground, + icon: Icons.bluetooth_connected, + text: 'OBD', + ) + : []; + + static List _gnss(Palette p, Reading fix, Reading sats) { + final n = sats.number?.round(); + final (icon, color) = switch (fix.value) { + '3d' => (Icons.gps_fixed, p.foreground), + '2d' => (Icons.gps_fixed, p.caution), + 'none' => (Icons.gps_not_fixed, p.caution), + _ => (Icons.gps_off, p.subdued), + }; + return _item( + fix.dim ? p.subdued : color, + icon: icon, + text: n == null ? null : '$n', + ); + } + + /// The modem, when there is one: bars from its signal, and the technology. + static List _lte( + Palette p, + Reading state, + Reading tech, + Reading pct, + ) { + final s = state.value as String?; + if (s == null) return []; + final q = pct.number ?? 0; + final t = (tech.value as String? ?? '').toUpperCase(); + final (icon, color) = switch (s) { + 'connected' => ( + q >= 60 + ? Icons.signal_cellular_alt + : q >= 30 + ? Icons.signal_cellular_alt_2_bar + : Icons.signal_cellular_alt_1_bar, + p.foreground, + ), + 'registered' || + 'searching' || + 'connecting' || + 'enabling' => (Icons.signal_cellular_alt_1_bar, p.caution), + _ => (Icons.signal_cellular_off, p.subdued), + }; + return _item( + state.dim ? p.subdued : color, + icon: icon, + text: t.isEmpty ? null : t, + ); + } + + static List _wifi(Palette p, Reading up, Reading dbm) { + if (!up.on) return _item(p.subdued, icon: Icons.wifi_off); + final d = dbm.number ?? -100; + final icon = d >= -60 + ? Icons.wifi + : d >= -70 + ? Icons.wifi_2_bar + : Icons.wifi_1_bar; + return _item(up.dim ? p.subdued : p.foreground, icon: icon); + } + + /// The UPS: caution on battery (the car is off), alarm under 20 %. + static List _ups(Palette p, Reading state, Reading pct) { + final n = pct.number; + if (n == null) return _item(p.subdued, icon: Icons.battery_unknown); + const bars = [ + Icons.battery_0_bar, + Icons.battery_1_bar, + Icons.battery_2_bar, + Icons.battery_3_bar, + Icons.battery_4_bar, + Icons.battery_5_bar, + Icons.battery_6_bar, + Icons.battery_full, + ]; + final charging = state.value == 'charging'; + final icon = charging + ? Icons.battery_charging_full + : bars[(n / 100 * 7).round().clamp(0, 7)]; + final color = n < 20 + ? p.alarm + : state.value == 'discharging' + ? p.caution + : p.foreground; + return _item( + pct.dim ? p.subdued : color, + icon: icon, + text: '${n.round()}%', + ); + } +} + +/// Tap: hold the other variant. Long press: back to auto (Settings has all three). +class _VariantButton extends StatelessWidget { + const _VariantButton({required this.themes}); + final Themes themes; + + @override + Widget build(BuildContext context) { + final p = AppTheme.of(context); + return GestureDetector( + onLongPress: () => themes.mode = VariantMode.auto, + child: IconButton( + tooltip: themes.isDark ? 'Bright' : 'Dark', + onPressed: themes.toggle, + iconSize: 20, + visualDensity: VisualDensity.compact, + icon: Icon( + themes.mode == VariantMode.auto + ? Icons.brightness_auto + : (themes.isDark ? Icons.light_mode : Icons.dark_mode), + color: p.subdued, + ), + ), + ); + } +}