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
+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 |
| `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.
+2 -46
View File
@@ -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,
),
),
),
],
),
);
}
}
+23 -6
View File
@@ -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<AppShell> {
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<AppShell> {
},
),
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(
+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,
),
),
);
}
}