From 5d0d2c00073cfc746c134cb3acef6721f4d8139f Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Thu, 15 Oct 2026 23:01:40 +0900 Subject: [PATCH] Status bar: part lamps (CAN, OBD, IMU, CAM), drawn signal and battery icons WiFi and LTE show signal in four steps with Android's traffic arrows; the battery is filled to its level in good (charging), caution (under 40 %) or alarm (under 20 %). OBD, IMU and CAM are lamps by CAN's rule; CAM reads orin-status' new cam_up and vision_up. Co-Authored-By: Claude Opus 5.5 --- docs/impl/plans/DASH-NEXT.md | 19 ++- lib/README.md | 3 +- lib/widgets/status_bar.dart | 270 ++++++++++++++++++++-------------- lib/widgets/status_icons.dart | 207 ++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 114 deletions(-) create mode 100644 lib/widgets/status_icons.dart diff --git a/docs/impl/plans/DASH-NEXT.md b/docs/impl/plans/DASH-NEXT.md index 5150f75..5f3e2e2 100644 --- a/docs/impl/plans/DASH-NEXT.md +++ b/docs/impl/plans/DASH-NEXT.md @@ -12,12 +12,21 @@ 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 +**Built (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. +link. Right: +- Orin temperature, only from 80 °C. +- Lamps for the parts, by one rule (foreground up, caution degraded, alarm down, subdued + unknown): CAN (`can_up`, bus state), OBD (`elm_connected`), IMU (its values fresh or + stale), CAM (`cam_up`, caution when `vision_up` is false; both new in orin-status). +- GNSS fix icon and satellite count. +- LTE (only with a modem) and WiFi: signal in four steps, Android's up/down traffic arrows + (lit above 2 kbit/s). +- UPS battery, filled to its level: good while charging, caution under 40 %, alarm under + 20 %, foreground otherwise. +- 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 diff --git a/lib/README.md b/lib/README.md index 02c6117..40017f5 100644 --- a/lib/README.md +++ b/lib/README.md @@ -29,7 +29,8 @@ 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 | +| `StatusBar` | `widgets/status_bar.dart` | The Orin like a phone's status bar, over every view: link; hot temperature; CAN, OBD, IMU, CAM lamps; GNSS fix and satellites; LTE and WiFi signal with traffic arrows; UPS battery; the dark/bright button | Yukart | +| `BatteryIcon`, `SignalIcon`, `TrafficArrows` | `widgets/status_icons.dart` | The status bar's drawn icons: a battery filled to its level in a role colour, four-step WiFi and cellular signal, Android's up/down traffic pair | Yukart | | `MockBanner` | `widgets/mock_banner.dart` | "MOCK DATA — NOT FROM THE CAR" in caution, over every page while the gateway is mocking | Yukart | ## Conventions diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index b51dae2..02e7cd7 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -1,17 +1,22 @@ // 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. +// left, then its parts, links out and 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. +// Colours are palette roles: foreground when fine, good for charging, caution when +// degraded or low, alarm when down, subdued when unknown. The parts (CAN, OBD, IMU, CAM) +// are lamps by one rule; WiFi and LTE show signal in four steps and Android's traffic +// arrows; the battery's fill shows its level in its colour. Temperature appears only +// when hot, LTE only with a modem. import 'package:flutter/material.dart'; import '../link/boundary.dart'; import '../link/reading.dart'; import '../theme/palette.dart'; +import 'status_icons.dart'; + +/// A part's state, for its lamp. +enum _Part { up, degraded, down, unknown } class StatusBar extends StatelessWidget { const StatusBar({ @@ -28,6 +33,9 @@ class StatusBar extends StatelessWidget { static const height = 32.0; + /// Traffic arrows light above this rate (kbit/s): over background chatter. + static const trafficKbps = 2.0; + @override Widget build(BuildContext context) { final p = AppTheme.of(context); @@ -57,16 +65,14 @@ class StatusBar extends StatelessWidget { // 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')), + _lamp(p, 'CAN', _can(r('can_up'), r('can_bus_state'))), + _lamp(p, 'OBD', _flag(r('elm_connected'))), + _lamp(p, 'IMU', _fresh(b, 'imu_roll_deg')), + _lamp(p, 'CAM', _cam(r('cam_up'), r('vision_up'))), + const SizedBox(width: 4), ..._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')), + ..._lte(p, r), + ..._wifi(p, r), ..._ups(p, r('ups_state'), r('ups_battery_pct')), ], _VariantButton(themes: themes), @@ -77,44 +83,68 @@ class StatusBar extends StatelessWidget { ); } - /// 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)), + /// One indicator: its pieces, and the gap after it. + static List _item(List pieces) => [ + ...pieces, const SizedBox(width: 14), ]; + static Widget _lamp(Palette p, String label, _Part s) => Padding( + padding: const EdgeInsets.only(right: 10), + child: Text( + label, + style: TextStyle( + color: switch (s) { + _Part.up => p.foreground, + _Part.degraded => p.caution, + _Part.down => p.alarm, + _Part.unknown => p.subdued, + }, + ), + ), + ); + + static _Part _flag(Reading on) => on.value == null + ? _Part.unknown + : on.dim + ? _Part.unknown + : on.on + ? _Part.up + : _Part.down; + + static _Part _can(Reading up, Reading state) => switch (_flag(up)) { + _Part.up => switch (state.value) { + 'BUS-OFF' || 'STOPPED' => _Part.down, + 'ERROR-WARNING' || 'ERROR-PASSIVE' => _Part.degraded, + _ => _Part.up, + }, + final s => s, + }; + + /// A part known only by its values arriving: fresh is up, stale is down. + static _Part _fresh(Boundary b, String name) { + final e = b.signals[name]; + if (e is! Map) return _Part.unknown; + return (e['quality'] as String? ?? 'ok') == 'ok' ? _Part.up : _Part.down; + } + + /// The camera: present and served is up; present but not served, degraded. + static _Part _cam(Reading cam, Reading vision) => switch (_flag(cam)) { + _Part.up => _flag(vision) == _Part.down ? _Part.degraded : _Part.up, + final s => s, + }; + /// 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()}°', - ); + final color = c >= 90 ? p.alarm : p.caution; + return _item([ + Icon(Icons.thermostat, color: color), + Text('${c.round()}°', style: TextStyle(color: color)), + ]); } - 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) { @@ -123,85 +153,109 @@ class StatusBar extends StatelessWidget { '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', - ); + final c = fix.dim ? p.subdued : color; + return _item([ + Icon(icon, color: c), + if (n != null) ...[ + const SizedBox(width: 2), + Text('$n', style: TextStyle(color: c)), + ], + ]); } - /// The modem, when there is one: bars from its signal, and the technology. - static List _lte( - Palette p, - Reading state, - Reading tech, - Reading pct, - ) { + static Widget _arrows(Reading Function(String) r, String net, Color c) => + TrafficArrows( + up: (r('net_${net}_tx_kbps').number ?? 0) > trafficKbps, + down: (r('net_${net}_rx_kbps').number ?? 0) > trafficKbps, + color: c, + ); + + /// The modem, when there is one: signal, traffic, and the technology. + static List _lte(Palette p, Reading Function(String) r) { + final state = r('net_lte_state'); 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, + final q = r('net_lte_signal_pct').number ?? 0; + final t = (r('net_lte_tech').value as String? ?? '').toUpperCase(); + final color = state.dim + ? p.subdued + : switch (s) { + 'connected' => p.foreground, + 'registered' || + 'searching' || + 'connecting' || + 'enabling' => p.caution, + _ => p.subdued, + }; + final connected = s == 'connected'; + final bars = q >= 80 + ? 4 + : q >= 60 + ? 3 + : q >= 40 + ? 2 + : q >= 20 + ? 1 + : 0; + return _item([ + if (t.isNotEmpty) ...[ + Text(t, style: TextStyle(color: color, fontSize: 11)), + const SizedBox(width: 2), + ], + SignalIcon.cellular( + bars: s == 'disabled' || s == 'failed' ? null : bars, + color: color, ), - '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, - ); + if (connected) ...[const SizedBox(width: 1), _arrows(r, 'lte', color)], + ]); } - 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); + static List _wifi(Palette p, Reading Function(String) r) { + final up = r('net_wifi_up'); + if (!up.on) { + return _item([SignalIcon.wifi(bars: null, color: p.subdued)]); + } + final d = r('net_wifi_signal_dbm').number ?? -100; + final bars = d >= -55 + ? 4 + : d >= -65 + ? 3 + : d >= -75 + ? 2 + : d >= -85 + ? 1 + : 0; + final color = up.dim ? p.subdued : p.foreground; + return _item([ + SignalIcon.wifi(bars: bars, color: color), + const SizedBox(width: 1), + _arrows(r, 'wifi', color), + ]); } - /// The UPS: caution on battery (the car is off), alarm under 20 %. + /// The UPS: the fill in good while charging, caution under 40 %, 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 + final fill = n == null || pct.dim + ? p.subdued + : state.value == 'charging' + ? p.good + : n < 20 ? p.alarm - : state.value == 'discharging' + : n < 40 ? p.caution : p.foreground; - return _item( - pct.dim ? p.subdued : color, - icon: icon, - text: '${n.round()}%', - ); + return _item([ + BatteryIcon( + level: n == null ? null : n / 100, + fill: fill, + outline: pct.dim ? p.subdued : p.foreground, + ), + if (n != null) ...[ + const SizedBox(width: 4), + Text('${n.round()}%', style: TextStyle(color: fill)), + ], + ]); } } diff --git a/lib/widgets/status_icons.dart b/lib/widgets/status_icons.dart new file mode 100644 index 0000000..ced77d9 --- /dev/null +++ b/lib/widgets/status_icons.dart @@ -0,0 +1,207 @@ +// The status bar's drawn icons, Android-like: a battery whose fill shows level and +// colour, WiFi and cellular signal in four steps, and the traffic arrow pair. Drawn +// rather than taken from the icon font, which has no four-step cellular icon and no +// separately coloured battery fill. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// A horizontal battery: [outline] for the case, the inside filled to [level] +/// (0–1, null unknown) in [fill]. +class BatteryIcon extends StatelessWidget { + const BatteryIcon({ + super.key, + required this.level, + required this.fill, + required this.outline, + }); + final double? level; + final Color fill; + final Color outline; + + @override + Widget build(BuildContext context) => CustomPaint( + size: const Size(24, 12), + painter: _Battery(level, fill, outline), + ); +} + +class _Battery extends CustomPainter { + _Battery(this.level, this.fill, this.outline); + final double? level; + final Color fill; + final Color outline; + + @override + void paint(Canvas c, Size s) { + const nub = 2.0; + final body = Rect.fromLTWH(0.75, 0.75, s.width - nub - 1.5, s.height - 1.5); + c.drawRRect( + RRect.fromRectAndRadius(body, const Radius.circular(2.5)), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..color = outline, + ); + c.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(s.width - nub, s.height * 0.3, nub, s.height * 0.4), + const Radius.circular(1), + ), + Paint()..color = outline, + ); + final l = level; + if (l == null) return; + final inner = body.deflate(2); + c.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + inner.left, + inner.top, + inner.width * l.clamp(0, 1), + inner.height, + ), + const Radius.circular(1), + ), + Paint()..color = fill, + ); + } + + @override + bool shouldRepaint(_Battery o) => + o.level != level || o.fill != fill || o.outline != outline; +} + +/// Signal in [bars] of four (null: off, drawn crossed), WiFi's fan or cellular's +/// triangle; the unlit part faint. +class SignalIcon extends StatelessWidget { + const SignalIcon.wifi({super.key, required this.bars, required this.color}) + : wifi = true; + const SignalIcon.cellular({ + super.key, + required this.bars, + required this.color, + }) : wifi = false; + final int? bars; + final Color color; + final bool wifi; + + @override + Widget build(BuildContext context) => CustomPaint( + size: const Size(18, 16), + painter: _Signal(bars, color, wifi), + ); +} + +class _Signal extends CustomPainter { + _Signal(this.bars, this.color, this.wifi); + final int? bars; + final Color color; + final bool wifi; + + @override + void paint(Canvas c, Size s) { + final faint = Paint()..color = color.withValues(alpha: 0.3); + final lit = Paint()..color = color; + final n = bars ?? 0; + if (wifi) { + // A quarter-circle fan standing on its point; lit from the point outwards. + final apex = Offset(s.width / 2, s.height); + Path fan(double r) => Path() + ..moveTo(apex.dx, apex.dy) + ..arcTo( + Rect.fromCircle(center: apex, radius: r), + -3 * math.pi / 4, + math.pi / 2, + false, + ) + ..close(); + final full = s.height; + c.drawPath(fan(full), faint); + if (n > 0) c.drawPath(fan(full * (0.25 + 0.75 * n / 4)), lit); + } else { + // A right triangle rising to the right; lit from the left. + final tri = Path() + ..moveTo(0, s.height) + ..lineTo(s.width, s.height) + ..lineTo(s.width, 0) + ..close(); + c.drawPath(tri, faint); + if (n > 0) { + c.save(); + c.clipRect( + Rect.fromLTWH(0, 0, s.width * (0.2 + 0.8 * n / 4), s.height), + ); + c.drawPath(tri, lit); + c.restore(); + } + } + if (bars == null) { + // Off: a stroke through it. + c.drawLine( + Offset(s.width * 0.15, s.height * 0.15), + Offset(s.width * 0.85, s.height * 0.95), + Paint() + ..color = color + ..strokeWidth = 1.5, + ); + } + } + + @override + bool shouldRepaint(_Signal o) => + o.bars != bars || o.color != color || o.wifi != wifi; +} + +/// Android's traffic pair beside a signal icon: up lit while sending, down while +/// receiving. +class TrafficArrows extends StatelessWidget { + const TrafficArrows({ + super.key, + required this.up, + required this.down, + required this.color, + }); + final bool up; + final bool down; + final Color color; + + @override + Widget build(BuildContext context) => + CustomPaint(size: const Size(6, 14), painter: _Arrows(up, down, color)); +} + +class _Arrows extends CustomPainter { + _Arrows(this.up, this.down, this.color); + final bool up; + final bool down; + final Color color; + + @override + void paint(Canvas c, Size s) { + Paint p(bool on) => + Paint()..color = on ? color : color.withValues(alpha: 0.25); + final h = s.height / 2 - 1; + c.drawPath( + Path() + ..moveTo(0, h) + ..lineTo(s.width, h) + ..lineTo(s.width / 2, 0) + ..close(), + p(up), + ); + c.drawPath( + Path() + ..moveTo(0, s.height - h) + ..lineTo(s.width, s.height - h) + ..lineTo(s.width / 2, s.height) + ..close(), + p(down), + ); + } + + @override + bool shouldRepaint(_Arrows o) => + o.up != up || o.down != down || o.color != color; +}