Status bar: the Orin at a glance over every view, like a phone's
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 20s
CI / Debug APK (push) Successful in 39s

Link on the left (with the menu on the Dash), then hot temperature, CAN, OBD while
connected, GNSS fix and satellites, LTE, WiFi, UPS, and the dark/bright button. The
Dash's own top line is gone into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 22:17:38 +09:00
parent 78a6564f0a
commit dcafc04cbc
5 changed files with 269 additions and 55 deletions
+10 -2
View File
@@ -12,6 +12,14 @@ what the Orin publishes.
## 1. Orin status in a top banner, like a phone's ## 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 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. 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 **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 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 status bar's button (it moved there with item 1) holds the other variant, and a long press
ambient light sensor as an auto source, and moving the button into the banner (item 1). returns it to auto. Not done: the light sensor as an auto source.
What was asked: What was asked:
Today the variant is a switch (the Dash's top-right button, and Settings). Wanted: Today the variant is a switch (the Dash's top-right button, and Settings). Wanted:
+2 -1
View File
@@ -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 | | `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 | | `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 | | `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 | | `MockBanner` | `widgets/mock_banner.dart` | "MOCK DATA — NOT FROM THE CAR" in caution, over every page while the gateway is mocking | Yukart |
## Conventions ## 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 `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`). 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 - **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. 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 - **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. `TextStyle`; everything else keeps the basic font, which covers CJK where DIN 1451 does not.
+2 -46
View File
@@ -6,7 +6,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../link/boundary.dart'; import '../link/boundary.dart';
import '../shell_scope.dart';
import '../link/reading.dart'; import '../link/reading.dart';
import '../theme/palette.dart'; import '../theme/palette.dart';
import '../widgets/accel_graph.dart'; import '../widgets/accel_graph.dart';
@@ -16,14 +15,13 @@ import '../widgets/telltale.dart';
import '../widgets/whiskey_mark.dart'; import '../widgets/whiskey_mark.dart';
class DashPage extends StatelessWidget { 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 Boundary boundary;
final Themes themes;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
listenable: Listenable.merge([boundary, themes]), listenable: boundary,
builder: (context, _) { builder: (context, _) {
final p = AppTheme.of(context); final p = AppTheme.of(context);
final b = boundary; final b = boundary;
@@ -50,7 +48,6 @@ class DashPage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_DashBar(boundary: b, themes: themes),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), 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,
),
),
),
],
),
);
}
}
+23 -6
View File
@@ -20,6 +20,7 @@ import 'shell_scope.dart';
import 'theme/palette.dart'; import 'theme/palette.dart';
import 'widgets/link_status.dart'; import 'widgets/link_status.dart';
import 'widgets/mock_banner.dart'; import 'widgets/mock_banner.dart';
import 'widgets/status_bar.dart';
class AppShell extends StatefulWidget { class AppShell extends StatefulWidget {
const AppShell({ const AppShell({
@@ -53,7 +54,7 @@ class _AppShellState extends State<AppShell> {
Widget _page(ShellView v) { Widget _page(ShellView v) {
final b = widget.boundary; final b = widget.boundary;
return switch (v) { 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.signals => SignalsPage(boundary: b, usb: widget.usb),
ShellView.captures => Navigator( ShellView.captures => Navigator(
key: _captures, key: _captures,
@@ -113,14 +114,30 @@ class _AppShellState extends State<AppShell> {
}, },
), ),
body: ListenableBuilder( body: ListenableBuilder(
listenable: widget.boundary, listenable: Listenable.merge([widget.boundary, widget.themes]),
builder: (context, child) => Column( builder: (context, child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (widget.boundary.mock) // The Orin's status over every view; the Dash, with no app bar, gets the menu in it.
const SafeArea(bottom: false, child: MockBanner()), SafeArea(
// Pages draw their own top (app bars); keep every bottom clear of the system bar. bottom: false,
Expanded(child: SafeArea(top: false, child: child!)), 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( child: IndexedStack(
+232
View File
@@ -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<Widget> _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<Widget> _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<Widget> _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<Widget> _elm(Palette p, Reading c) => c.on
? _item(
c.dim ? p.subdued : p.foreground,
icon: Icons.bluetooth_connected,
text: 'OBD',
)
: [];
static List<Widget> _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<Widget> _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<Widget> _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<Widget> _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,
),
),
);
}
}