dcafc04cbc
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>
233 lines
7.4 KiB
Dart
233 lines
7.4 KiB
Dart
// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|