Files
app-flutter/lib/widgets/status_bar.dart
T
mikkeli 5d0d2c0007
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 21s
CI / Debug APK (push) Successful in 40s
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 <noreply@anthropic.com>
2026-10-15 23:01:40 +09:00

287 lines
8.8 KiB
Dart

// The Orin at a glance, the way a phone's status bar shows the phone: the link on the
// 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).
//
// 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({
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;
/// 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);
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')),
_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),
..._wifi(p, r),
..._ups(p, r('ups_state'), r('ups_battery_pct')),
],
_VariantButton(themes: themes),
],
),
),
),
);
}
/// One indicator: its pieces, and the gap after it.
static List<Widget> _item(List<Widget> 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<Widget> _temperature(Palette p, Reading t) {
final c = t.number;
if (t.dim || c == null || c < 80) return [];
final color = c >= 90 ? p.alarm : p.caution;
return _item([
Icon(Icons.thermostat, color: color),
Text('${c.round()}°', style: TextStyle(color: color)),
]);
}
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),
};
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)),
],
]);
}
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<Widget> _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 = 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,
),
if (connected) ...[const SizedBox(width: 1), _arrows(r, 'lte', color)],
]);
}
static List<Widget> _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: the fill in good while charging, caution under 40 %, alarm under 20 %.
static List<Widget> _ups(Palette p, Reading state, Reading pct) {
final n = pct.number;
final fill = n == null || pct.dim
? p.subdued
: state.value == 'charging'
? p.good
: n < 20
? p.alarm
: n < 40
? p.caution
: p.foreground;
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)),
],
]);
}
}
/// 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,
),
),
);
}
}