5d0d2c0007
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>
287 lines
8.8 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|