Status bar: the Orin at a glance over every view, like a phone's
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:
@@ -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:
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user