App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 27s

The shell (shell.dart): a drawer from the left edge or each page's menu button.
Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the
rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright,
build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals,
signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its
own navigator, so a run survives switching views. Back goes inside Captures, then to
the landing view, then out. The mock banner sits over every page.

main.dart is startup only; the old home screen is pages/signals.dart; dialogs and
sheets moved to actions/; the protocol client, USB binding and Reading to link/.
lib/README.md catalogues it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-14 14:08:56 +09:00
parent 3aeb09d61f
commit 4cf7a786d6
22 changed files with 941 additions and 579 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ date: 2026-10-11
Does the PD hub degrade the tablet ⇄ Orin USB (NCM) link? Measured from the app Does the PD hub degrade the tablet ⇄ Orin USB (NCM) link? Measured from the app
(the only place on the tablet whose sockets use the link) with the link test, (the only place on the tablet whose sockets use the link) with the link test,
`lib/linktest.dart` + `tool/linktest_server.py`. `lib/linktest.dart` (now `lib/pages/link_test.dart`) + `tool/linktest_server.py`.
## Short version ## Short version
+12 -4
View File
@@ -3,15 +3,21 @@ type: reference
--- ---
# The app's code: a catalogue # The app's code: a catalogue
Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from. Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from; the menu follows
pirate-station-aide's (main views, then the rarely used ones grouped as Tools).
## Folders ## Folders
| Where | Holds | | Where | Holds |
|---|---| |---|---|
| `lib/*.dart` | Pages (`dash`, `checklist`, `camview`, `linktest`), the boundary client (`boundary`), the USB network binding (`net`), and `reading`, one signal's state as a display needs it | | `main.dart` | Startup: the link to the Orin, the theme, then the shell |
| `theme/` | `palette.dart`: the palette and `hudFont` | | `shell.dart` | The shell: a menu that swipes in from the left; Dash, Signals, Captures, then Tools (Camera, Link test, Orin power options), then Settings. Dash, Signals and Captures stay alive while hidden |
| `widgets/` | Reusable display widgets, one per file, each fed values, never the boundary | | `shell_scope.dart` | What a page needs from the shell: `MenuButton`, `ShellView`, the landing view (`--dart-define=YUKART_HOME=dash\|signals`, `signals` for now) |
| `pages/` | One file per view: `dash`, `signals` (values and raw traffic), `checklist` (Captures), `camera`, `link_test`, `settings` |
| `actions/` | Things done from anywhere, as dialogs and sheets: `marks` (free-form mark), `orin_power` (power off / reboot), `server_url` (gateway address) |
| `link/` | Talking to the Orin: `boundary` (the protocol client), `net` (binding to the USB link), `reading` (one signal's state as a display needs it) |
| `theme/` | `palette.dart`: the palette roles and `hudFont` |
| `widgets/` | Reusable display widgets, one per file, each fed values, never the boundary (`link_status` takes it whole: it shows the link) |
## Widgets ## Widgets
@@ -22,6 +28,8 @@ Laid out like smart-serow's HUD (`pi/ui/lib/`), which several pieces come from.
| `WhiskeyMark` | `widgets/whiskey_mark.dart` | Attitude: a horizon tilting with roll, ladder moving with pitch | smart-serow | | `WhiskeyMark` | `widgets/whiskey_mark.dart` | Attitude: a horizon tilting with roll, ladder moving with pitch | smart-serow |
| `AccelGraph` | `widgets/accel_graph.dart` | G-meter: a dot for lateral/longitudinal g, a trace and a peak marker over a window | smart-serow | | `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 | | `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 |
| `MockBanner` | `widgets/mock_banner.dart` | "MOCK DATA — NOT FROM THE CAR" in caution, over every page while the gateway is mocking | Yukart |
## Conventions ## Conventions
+72
View File
@@ -0,0 +1,72 @@
// The free-form mark: stamped into the open recording from anywhere, beside the
// checklist's structured ones.
import 'dart:math';
import 'package:flutter/material.dart';
import '../link/boundary.dart';
const quickMarks = [
'cold start',
'engine start',
'indicator left',
'indicator right',
'brake',
'headlights',
'something odd',
];
Future<void> showMarkSheet(BuildContext context, Boundary b) {
final custom = TextEditingController();
return showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) {
void send(String text) {
if (text.trim().isEmpty) return;
b.command('mark', {'text': text.trim()});
Navigator.pop(ctx);
}
return Padding(
// Clear the keyboard when it is up, the navigation bar when it is not.
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 +
max(
MediaQuery.of(ctx).viewInsets.bottom,
MediaQuery.of(ctx).viewPadding.bottom,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Stamp into the recording',
style: Theme.of(ctx).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final q in quickMarks)
ActionChip(label: Text(q), onPressed: () => send(q)),
],
),
TextField(
controller: custom,
decoration: const InputDecoration(labelText: 'Other'),
onSubmitted: send,
),
],
),
);
},
);
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../link/boundary.dart';
import '../theme/palette.dart';
/// Power off or reboot the Orin: one confirmation that is also the choice. Sent once,
/// never resent (contracts BOUNDARY.md: the ack cache does not survive the restart).
Future<void> orinPowerOptions(BuildContext context, Boundary b) async {
final host = b.hello?['host'] ?? 'the Orin';
final verb = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Power options: $host'),
content: const Text(
'Either way, recording stops and the session is closed cleanly.\n\n'
'Power off: the Orin stays off until its power button is pressed.\n'
'Reboot: it is back in about a minute; this screen reconnects by itself.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop('reboot'),
icon: const Icon(Icons.restart_alt),
label: const Text('Reboot'),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: AppTheme.of(context).alarm,
foregroundColor: AppTheme.of(context)
.onFill(AppTheme.of(context).alarm),
),
onPressed: () => Navigator.of(context).pop('poweroff'),
icon: const Icon(Icons.power_settings_new),
label: const Text('Power off'),
),
],
),
);
if (verb == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final id = b.commandOnce(verb, {
'confirm': verb,
'boot_id': b.hello?['boot_id'],
});
if (id == null) {
messenger.showSnackBar(
const SnackBar(content: Text('Not sent: no link to the Orin')),
);
return;
}
final ack = await b.ackFor(id);
final what = verb == 'reboot' ? 'rebooting' : 'shutting down';
messenger.showSnackBar(
SnackBar(
content: Text(
ack == null
? 'No reply: the Orin may or may not be $what'
: ack.ok
? 'Orin: ${ack.detail}'
: 'Refused: ${ack.detail}',
),
),
);
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../link/boundary.dart';
import '../link/net.dart';
/// Ask for the gateway's address and reconnect there, binding to the USB link when it is on it.
Future<void> editUrl(BuildContext context, Boundary b, UsbLink usb) async {
final c = TextEditingController(text: b.url);
final url = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gateway address'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, c.text.trim()),
child: const Text('Connect'),
),
],
),
);
if (url == null || url.isEmpty) return;
await usb.bindFor(Uri.parse(url));
b.retarget(url);
}
View File
+19 -535
View File
@@ -1,16 +1,13 @@
// Yukart display — starter. A text readout of whatever svc-gw sends, proving the // Yukart display: the tablet app. Starts the link to the Orin and hands the screen to
// boundary contract before anything is drawn (initial plan, step 1). // the shell (lib/shell.dart). Code layout: lib/README.md.
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'boundary.dart'; import 'link/boundary.dart';
import 'camview.dart'; import 'link/net.dart';
import 'checklist.dart'; import 'pages/link_test.dart';
import 'dash.dart'; import 'shell.dart';
import 'linktest.dart'; import 'shell_scope.dart';
import 'net.dart';
import 'theme/palette.dart'; import 'theme/palette.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`. /// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
@@ -21,7 +18,7 @@ const defaultUrl = String.fromEnvironment(
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
themes = await Themes.load(); final themes = await Themes.load();
final usb = UsbLink(); final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl)); await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start(); final boundary = Boundary(defaultUrl)..start();
@@ -38,11 +35,7 @@ Future<void> main() async {
final host = at < 0 final host = at < 0
? Uri.parse(boundary.url).host ? Uri.parse(boundary.url).host
: request.substring(at + 1); : request.substring(at + 1);
navigatorKey.currentState?.push( shellView.value = ShellView.linkTest;
MaterialPageRoute<void>(
builder: (_) => linkTestPage(boundary, linkTest, usb),
),
);
await switch (mode) { await switch (mode) {
'dl' => linkTest.runDownload(host, label), 'dl' => linkTest.runDownload(host, label),
'bi' => linkTest.runBurstIdle(host, label), 'bi' => linkTest.runBurstIdle(host, label),
@@ -52,34 +45,27 @@ Future<void> main() async {
} }
usb.onLinkTest = runLinkTest; usb.onLinkTest = runLinkTest;
runApp(YukartApp(boundary: boundary, usb: usb, linkTest: linkTest)); runApp(
YukartApp(boundary: boundary, usb: usb, linkTest: linkTest, themes: themes),
);
final pending = await usb.takeLinkTest(); final pending = await usb.takeLinkTest();
if (pending != null) { if (pending != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => runLinkTest(pending)); WidgetsBinding.instance.addPostFrameCallback((_) => runLinkTest(pending));
} }
} }
final navigatorKey = GlobalKey<NavigatorState>();
/// The palette (lib/theme/palette.dart), loaded before the first frame.
late final Themes themes;
Widget linkTestPage(Boundary b, LinkTest t, UsbLink usb) => LinkTestPage(
test: t,
host: Uri.parse(b.url).host,
restoreBinding: () => usb.bindFor(Uri.parse(b.url)),
);
class YukartApp extends StatelessWidget { class YukartApp extends StatelessWidget {
const YukartApp({ const YukartApp({
super.key, super.key,
required this.boundary, required this.boundary,
required this.usb, required this.usb,
required this.linkTest, required this.linkTest,
required this.themes,
}); });
final Boundary boundary; final Boundary boundary;
final UsbLink usb; final UsbLink usb;
final LinkTest linkTest; final LinkTest linkTest;
final Themes themes;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -88,519 +74,17 @@ class YukartApp extends StatelessWidget {
listenable: themes, listenable: themes,
builder: (context, _) => MaterialApp( builder: (context, _) => MaterialApp(
title: 'Yukart', title: 'Yukart',
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: themes.material(), theme: themes.material(),
builder: (context, child) => builder: (context, child) =>
AppTheme(palette: themes.current, child: child!), AppTheme(palette: themes.current, child: child!),
home: ListenableBuilder( home: AppShell(
listenable: Listenable.merge([boundary, usb]), boundary: boundary,
builder: (context, _) => usb: usb,
Readout(boundary: boundary, usb: usb, linkTest: linkTest), linkTest: linkTest,
themes: themes,
), ),
), ),
); );
} }
} }
class Readout extends StatelessWidget {
const Readout({
super.key,
required this.boundary,
required this.usb,
required this.linkTest,
});
final Boundary boundary;
final UsbLink usb;
final LinkTest linkTest;
@override
Widget build(BuildContext context) {
final b = boundary;
return Scaffold(
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Mock data must look like mock data: on the surface, not in a menu.
if (b.mock) const MockBanner(),
LinkBar(boundary: b, usb: usb, linkTest: linkTest),
Expanded(
child: LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth > 800;
final signals = SignalList(boundary: b);
final raw = RawLog(boundary: b);
return wide
? Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 3, child: signals),
const VerticalDivider(width: 1),
Expanded(flex: 2, child: raw),
],
)
: Column(
children: [
Expanded(child: signals),
const Divider(height: 1),
Expanded(child: raw),
],
);
},
),
),
],
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => showMarkSheet(context, b),
icon: const Icon(Icons.flag),
label: Text(
b.pending.isEmpty ? 'Mark' : 'Mark (${b.pending.length} pending)',
),
),
);
}
}
class MockBanner extends StatelessWidget {
const MockBanner({super.key});
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
return Container(
color: p.caution,
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: p.onFill(p.caution),
fontWeight: FontWeight.w900,
fontSize: 20,
letterSpacing: 2,
),
),
);
}
}
class LinkBar extends StatelessWidget {
const LinkBar({
super.key,
required this.boundary,
required this.usb,
required this.linkTest,
});
final Boundary boundary;
final UsbLink usb;
final LinkTest linkTest;
@override
Widget build(BuildContext context) {
final b = boundary;
final p = AppTheme.of(context);
final (color, label) = switch (b.state) {
LinkState.up => (p.good, 'CONNECTED'),
LinkState.connecting => (p.caution, 'CONNECTING'),
LinkState.down => (p.alarm, 'DOWN'),
};
// Fixed-width numbers, padded with figure spaces (U+2007, digit-wide): the bar must
// not reflow every time a value gains a digit.
String pad(String v, int n) => v.padLeft(n, '\u2007');
final age = b.lastMessage == null
? pad('—', 4)
: pad(
'${DateTime.now().difference(b.lastMessage!).inMilliseconds.clamp(0, 9999)}',
4,
);
final offset = b.clockOffset == null
? pad('—', 6)
: pad((b.clockOffset!.inMilliseconds / 1000).toStringAsFixed(1), 6);
const fixed = TextStyle(fontFeatures: [FontFeature.tabularFigures()]);
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: InkWell(
onTap: () => editUrl(context, b, usb),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Wrap(
spacing: 16,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(
label: Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
backgroundColor: color.withValues(alpha: 0.3),
),
Text(b.url, style: const TextStyle(fontFamily: 'monospace')),
Text(b.detail),
Text('last msg $age ms', style: fixed),
Text('Orin−tablet clock $offset s', style: fixed),
if (usb.supported)
Text(
'USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}',
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DashPage(boundary: b, themes: themes),
),
),
icon: const Icon(Icons.dashboard),
label: const Text('Dash'),
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => linkTestPage(b, linkTest, usb),
),
),
icon: const Icon(Icons.speed),
label: const Text('Link test'),
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CamViewPage(host: Uri.parse(b.url).host),
),
),
icon: const Icon(Icons.videocam),
label: const Text('Camera'),
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChecklistsPage(boundary: b),
),
),
icon: const Icon(Icons.checklist),
label: const Text('Captures'),
),
if (b.state == LinkState.up && b.hello?['boot_id'] is String)
TextButton.icon(
onPressed: () => orinPowerOptions(context, b),
icon: const Icon(Icons.power_settings_new),
label: const Text('Orin power options'),
),
],
),
),
),
);
}
}
/// Confirm, then send `poweroff` once (contracts BOUNDARY.md): it carries this
/// connection's boot id, so it can never take effect on a later boot.
/// Power off or reboot the Orin: one confirmation that is also the choice. Sent once,
/// never resent (contracts BOUNDARY.md: the ack cache does not survive the restart).
Future<void> orinPowerOptions(BuildContext context, Boundary b) async {
final host = b.hello?['host'] ?? 'the Orin';
final verb = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Power options: $host'),
content: const Text(
'Either way, recording stops and the session is closed cleanly.\n\n'
'Power off: the Orin stays off until its power button is pressed.\n'
'Reboot: it is back in about a minute; this screen reconnects by itself.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop('reboot'),
icon: const Icon(Icons.restart_alt),
label: const Text('Reboot'),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: AppTheme.of(context).alarm,
foregroundColor: AppTheme.of(context).onFill(AppTheme.of(context).alarm),
),
onPressed: () => Navigator.of(context).pop('poweroff'),
icon: const Icon(Icons.power_settings_new),
label: const Text('Power off'),
),
],
),
);
if (verb == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final id = b.commandOnce(verb, {
'confirm': verb,
'boot_id': b.hello?['boot_id'],
});
if (id == null) {
messenger.showSnackBar(
const SnackBar(content: Text('Not sent: no link to the Orin')),
);
return;
}
final ack = await b.ackFor(id);
final what = verb == 'reboot' ? 'rebooting' : 'shutting down';
messenger.showSnackBar(
SnackBar(
content: Text(
ack == null
? 'No reply: the Orin may or may not be $what'
: ack.ok
? 'Orin: ${ack.detail}'
: 'Refused: ${ack.detail}',
),
),
);
}
class SignalList extends StatelessWidget {
const SignalList({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
final b = boundary;
if (b.state != LinkState.up) {
return const Center(child: Text('No link to the Orin'));
}
if (b.seen.isEmpty) {
return Center(
child: Text(
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
textAlign: TextAlign.center,
),
);
}
// Rows stay put: a signal that drops out is shown as missing, not removed.
final names = b.seen.keys.toList()..sort();
return ListView(
padding: const EdgeInsets.all(12),
children: [
for (final n in names)
b.signals[n] is Map
? SignalTile(
name: n,
envelope: Map<String, dynamic>.from(b.signals[n] as Map),
)
: SignalTile(name: n, envelope: b.seen[n]!, missing: true),
],
);
}
}
/// Missing, known-without-value, stale and fresh each look different: all four are normal in a car.
class SignalTile extends StatelessWidget {
const SignalTile({
super.key,
required this.name,
required this.envelope,
this.missing = false,
});
final String name;
/// The current envelope, or the last one seen when [missing] (only its unit is used then).
final Map<String, dynamic> envelope;
final bool missing;
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
if (missing) {
return ListTile(
title: Text(
name,
style: TextStyle(fontFamily: 'monospace', color: p.subdued),
),
subtitle: Text(
'MISSING · not in the latest state',
style: TextStyle(color: p.alarm),
),
trailing: Text.rich(
TextSpan(
children: [
TextSpan(
text: '?',
style: TextStyle(fontSize: 34, color: p.subdued),
),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: TextStyle(fontSize: 16, color: p.subdued),
),
],
),
),
);
}
final v = envelope['value'];
final quality = envelope['quality'] as String? ?? 'ok';
final stale = quality != 'ok';
final text = v == null
? '—'
: (v is double && v == v.roundToDouble() ? v.toInt().toString() : '$v');
final style = TextStyle(
fontSize: 34,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: stale || v == null ? p.subdued : p.foreground,
fontStyle: stale ? FontStyle.italic : null,
);
return ListTile(
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
style: stale ? TextStyle(color: p.caution) : null,
[
if (stale) quality.toUpperCase(),
if (v == null) 'no value',
if (envelope['mock'] == true) 'mock',
].join(' · '),
),
trailing: Text.rich(
TextSpan(
children: [
TextSpan(text: text, style: style),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: const TextStyle(fontSize: 16),
),
],
),
),
);
}
}
class RawLog extends StatelessWidget {
const RawLog({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
final b = boundary;
return ListView(
padding: const EdgeInsets.all(8),
children: [
if (b.pending.isNotEmpty || b.acks.isNotEmpty) ...[
Text('Commands', style: Theme.of(context).textTheme.titleSmall),
for (final p in b.pending)
Text(
'… ${p.cmd} ${p.args['text'] ?? ''} (unacked)',
style: TextStyle(color: AppTheme.of(context).caution),
),
for (final a in b.acks)
Text(
'${a.ok ? '✓' : '✗'} ${a.detail}',
style: TextStyle(color: a.ok ? AppTheme.of(context).good : AppTheme.of(context).alarm),
),
const Divider(),
],
Text(
'Raw messages, newest first',
style: Theme.of(context).textTheme.titleSmall,
),
for (final r in b.recent)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
r,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
],
);
}
}
const quickMarks = [
'cold start',
'engine start',
'indicator left',
'indicator right',
'brake',
'headlights',
'something odd',
];
Future<void> showMarkSheet(BuildContext context, Boundary b) {
final custom = TextEditingController();
return showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) {
void send(String text) {
if (text.trim().isEmpty) return;
b.command('mark', {'text': text.trim()});
Navigator.pop(ctx);
}
return Padding(
// Clear the keyboard when it is up, the navigation bar when it is not.
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 +
max(
MediaQuery.of(ctx).viewInsets.bottom,
MediaQuery.of(ctx).viewPadding.bottom,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Stamp into the recording',
style: Theme.of(ctx).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final q in quickMarks)
ActionChip(label: Text(q), onPressed: () => send(q)),
],
),
TextField(
controller: custom,
decoration: const InputDecoration(labelText: 'Other'),
onSubmitted: send,
),
],
),
);
},
);
}
Future<void> editUrl(BuildContext context, Boundary b, UsbLink usb) async {
final c = TextEditingController(text: b.url);
final url = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gateway address'),
content: TextField(controller: c, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, c.text.trim()),
child: const Text('Connect'),
),
],
),
);
if (url == null || url.isEmpty) return;
await usb.bindFor(Uri.parse(url));
b.retarget(url);
}
+3 -2
View File
@@ -10,7 +10,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'theme/palette.dart'; import '../shell_scope.dart';
import '../theme/palette.dart';
const camviewPort = 7710; const camviewPort = 7710;
@@ -168,7 +169,7 @@ class _CamViewPageState extends State<CamViewPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.of(context).background, backgroundColor: AppTheme.of(context).background,
appBar: AppBar(title: const Text('Camera (test)')), appBar: AppBar(leading: const MenuButton(), title: const Text('Camera (test)')),
body: SafeArea( body: SafeArea(
child: ListenableBuilder( child: ListenableBuilder(
listenable: cam, listenable: cam,
@@ -17,8 +17,9 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'boundary.dart'; import '../link/boundary.dart';
import 'theme/palette.dart'; import '../shell_scope.dart';
import '../theme/palette.dart';
const checklistAssets = 'assets/contracts/checklists/'; const checklistAssets = 'assets/contracts/checklists/';
@@ -197,13 +198,13 @@ class ChecklistsPage extends StatelessWidget {
future: loadChecklists(), future: loadChecklists(),
builder: (context, snap) { builder: (context, snap) {
if (snap.hasError) { if (snap.hasError) {
return Scaffold(appBar: AppBar(title: const Text('Captures')), body: Center(child: Text('${snap.error}'))); return Scaffold(appBar: AppBar(leading: const MenuButton(), title: const Text('Captures')), body: Center(child: Text('${snap.error}')));
} }
final lists = snap.data; final lists = snap.data;
if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single); if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Captures')), appBar: AppBar(leading: const MenuButton(), title: const Text('Captures')),
body: ListView( body: ListView(
children: [ children: [
if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')), if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')),
+11 -27
View File
@@ -5,14 +5,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'boundary.dart'; import '../link/boundary.dart';
import 'reading.dart'; import '../shell_scope.dart';
import 'theme/palette.dart'; import '../link/reading.dart';
import 'widgets/accel_graph.dart'; import '../theme/palette.dart';
import 'widgets/gps_compass.dart'; import '../widgets/accel_graph.dart';
import 'widgets/stat_box.dart'; import '../widgets/gps_compass.dart';
import 'widgets/telltale.dart'; import '../widgets/stat_box.dart';
import 'widgets/whiskey_mark.dart'; import '../widgets/telltale.dart';
import '../widgets/whiskey_mark.dart';
class DashPage extends StatelessWidget { class DashPage extends StatelessWidget {
const DashPage({super.key, required this.boundary, required this.themes}); const DashPage({super.key, required this.boundary, required this.themes});
@@ -47,20 +48,6 @@ class DashPage extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (b.mock)
Container(
color: p.caution,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: p.onFill(p.caution),
fontWeight: FontWeight.w900,
letterSpacing: 2,
),
),
),
_DashBar(boundary: b, themes: themes), _DashBar(boundary: b, themes: themes),
Expanded( Expanded(
child: Padding( child: Padding(
@@ -175,7 +162,7 @@ class DashPage extends StatelessWidget {
} }
} }
/// The dash's own thin top line: link state, back, and the dark/bright switch. /// The dash's own thin top line: the menu, link state, and the dark/bright switch.
class _DashBar extends StatelessWidget { class _DashBar extends StatelessWidget {
const _DashBar({required this.boundary, required this.themes}); const _DashBar({required this.boundary, required this.themes});
final Boundary boundary; final Boundary boundary;
@@ -193,10 +180,7 @@ class _DashBar extends StatelessWidget {
height: 40, height: 40,
child: Row( child: Row(
children: [ children: [
IconButton( MenuButton(color: p.subdued),
onPressed: () => Navigator.of(context).maybePop(),
icon: Icon(Icons.arrow_back, color: p.subdued),
),
Text(text, style: TextStyle(color: color, letterSpacing: 2)), Text(text, style: TextStyle(color: color, letterSpacing: 2)),
const Spacer(), const Spacer(),
IconButton( IconButton(
@@ -11,7 +11,8 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'net.dart'; import '../link/net.dart';
import '../shell_scope.dart';
const _down = 7791, _up = 7792, _echo = 7793, _results = 7794; const _down = 7791, _up = 7792, _echo = 7793, _results = 7794;
@@ -419,7 +420,7 @@ class _LinkTestPageState extends State<LinkTestPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Link test')), appBar: AppBar(leading: const MenuButton(), title: const Text('Link test')),
body: ListenableBuilder( body: ListenableBuilder(
listenable: widget.test, listenable: widget.test,
builder: (context, _) { builder: (context, _) {
+83
View File
@@ -0,0 +1,83 @@
// Settings: the few things that are chosen, gathered in one place. Build-time choices
// are shown so it is clear where they come from, not edited here.
import 'package:flutter/material.dart';
import '../actions/server_url.dart';
import '../link/boundary.dart';
import '../link/net.dart';
import '../shell_scope.dart';
import '../theme/palette.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({
super.key,
required this.boundary,
required this.usb,
required this.themes,
});
final Boundary boundary;
final UsbLink usb;
final Themes themes;
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
Widget section(String t) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
child: Text(
t,
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: p.subdued),
),
);
return ListenableBuilder(
listenable: Listenable.merge([boundary, themes]),
builder: (context, _) => Scaffold(
appBar: AppBar(
leading: const MenuButton(),
title: const Text('Settings'),
),
body: ListView(
children: [
section('Connection'),
ListTile(
leading: const Icon(Icons.lan),
title: const Text('Gateway address'),
subtitle: Text(
boundary.url,
style: const TextStyle(fontFamily: 'monospace'),
),
trailing: const Icon(Icons.edit),
onTap: () => editUrl(context, boundary, usb),
),
section('Display'),
SwitchListTile(
secondary: Icon(
themes.isDark ? Icons.dark_mode : Icons.light_mode,
),
title: const Text('Dark variant'),
subtitle: const Text('Bright for daylight, dark for night'),
value: themes.isDark,
onChanged: (v) => themes.isDark = v,
),
ListTile(
leading: const Icon(Icons.palette),
title: const Text('Theme'),
subtitle: Text(
'${themes.name} · set at build time: --dart-define=YUKART_THEME=<name>',
),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Landing view'),
subtitle: Text(
'${landingView.name} · set at build time: --dart-define=YUKART_HOME=dash|signals',
),
),
],
),
),
);
}
}
+247
View File
@@ -0,0 +1,247 @@
// Signals: every value the gateway sends, as a list, beside the raw traffic. The
// development landing view (lib/shell_scope.dart).
import 'package:flutter/material.dart';
import '../actions/marks.dart';
import '../actions/server_url.dart';
import '../link/boundary.dart';
import '../link/net.dart';
import '../shell_scope.dart';
import '../theme/palette.dart';
import '../widgets/link_status.dart';
class SignalsPage extends StatelessWidget {
const SignalsPage({super.key, required this.boundary, required this.usb});
final Boundary boundary;
final UsbLink usb;
@override
Widget build(BuildContext context) {
final b = boundary;
return ListenableBuilder(
listenable: Listenable.merge([boundary, usb]),
builder: (context, _) => Scaffold(
appBar: AppBar(
leading: const MenuButton(),
title: const Text('Signals'),
actions: [
LinkChip(boundary: b),
const SizedBox(width: 12),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Material(
color: AppTheme.of(context).surface,
child: InkWell(
onTap: () => editUrl(context, b, usb),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: LinkDetails(boundary: b, usb: usb),
),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, c) {
final wide = c.maxWidth > 800;
final signals = SignalList(boundary: b);
final raw = RawLog(boundary: b);
return wide
? Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 3, child: signals),
const VerticalDivider(width: 1),
Expanded(flex: 2, child: raw),
],
)
: Column(
children: [
Expanded(child: signals),
const Divider(height: 1),
Expanded(child: raw),
],
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => showMarkSheet(context, b),
icon: const Icon(Icons.flag),
label: Text(
b.pending.isEmpty ? 'Mark' : 'Mark (${b.pending.length} pending)',
),
),
),
);
}
}
class SignalList extends StatelessWidget {
const SignalList({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
final b = boundary;
if (b.state != LinkState.up) {
return const Center(child: Text('No link to the Orin'));
}
if (b.seen.isEmpty) {
return Center(
child: Text(
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
textAlign: TextAlign.center,
),
);
}
// Rows stay put: a signal that drops out is shown as missing, not removed.
final names = b.seen.keys.toList()..sort();
return ListView(
padding: const EdgeInsets.all(12),
children: [
for (final n in names)
b.signals[n] is Map
? SignalTile(
name: n,
envelope: Map<String, dynamic>.from(b.signals[n] as Map),
)
: SignalTile(name: n, envelope: b.seen[n]!, missing: true),
],
);
}
}
/// Missing, known-without-value, stale and fresh each look different: all four are normal in a car.
class SignalTile extends StatelessWidget {
const SignalTile({
super.key,
required this.name,
required this.envelope,
this.missing = false,
});
final String name;
/// The current envelope, or the last one seen when [missing] (only its unit is used then).
final Map<String, dynamic> envelope;
final bool missing;
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
if (missing) {
return ListTile(
title: Text(
name,
style: TextStyle(fontFamily: 'monospace', color: p.subdued),
),
subtitle: Text(
'MISSING · not in the latest state',
style: TextStyle(color: p.alarm),
),
trailing: Text.rich(
TextSpan(
children: [
TextSpan(
text: '?',
style: TextStyle(fontSize: 34, color: p.subdued),
),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: TextStyle(fontSize: 16, color: p.subdued),
),
],
),
),
);
}
final v = envelope['value'];
final quality = envelope['quality'] as String? ?? 'ok';
final stale = quality != 'ok';
final text = v == null
? '—'
: (v is double && v == v.roundToDouble() ? v.toInt().toString() : '$v');
final style = TextStyle(
fontSize: 34,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: stale || v == null ? p.subdued : p.foreground,
fontStyle: stale ? FontStyle.italic : null,
);
return ListTile(
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
style: stale ? TextStyle(color: p.caution) : null,
[
if (stale) quality.toUpperCase(),
if (v == null) 'no value',
if (envelope['mock'] == true) 'mock',
].join(' · '),
),
trailing: Text.rich(
TextSpan(
children: [
TextSpan(text: text, style: style),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: const TextStyle(fontSize: 16),
),
],
),
),
);
}
}
class RawLog extends StatelessWidget {
const RawLog({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
final b = boundary;
return ListView(
padding: const EdgeInsets.all(8),
children: [
if (b.pending.isNotEmpty || b.acks.isNotEmpty) ...[
Text('Commands', style: Theme.of(context).textTheme.titleSmall),
for (final p in b.pending)
Text(
'… ${p.cmd} ${p.args['text'] ?? ''} (unacked)',
style: TextStyle(color: AppTheme.of(context).caution),
),
for (final a in b.acks)
Text(
'${a.ok ? '✓' : '✗'} ${a.detail}',
style: TextStyle(
color: a.ok
? AppTheme.of(context).good
: AppTheme.of(context).alarm,
),
),
const Divider(),
],
Text(
'Raw messages, newest first',
style: Theme.of(context).textTheme.titleSmall,
),
for (final r in b.recent)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
r,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
),
),
],
);
}
}
+239
View File
@@ -0,0 +1,239 @@
// The shell: one page at a time, chosen from a menu that swipes in from the left
// (or opens from each page's menu button). Main views first, then Tools, the rarely
// used ones (pirate-station-aide's pattern), then Settings.
//
// Dash, Signals and Captures stay alive while hidden, so a capture run survives a look
// at the dash; Camera and Link test are built only while shown (the camera holds a stream).
import 'package:flutter/material.dart';
import 'actions/orin_power.dart';
import 'link/boundary.dart';
import 'link/net.dart';
import 'pages/camera.dart';
import 'pages/checklist.dart';
import 'pages/dash.dart';
import 'pages/link_test.dart';
import 'pages/settings.dart';
import 'pages/signals.dart';
import 'shell_scope.dart';
import 'theme/palette.dart';
import 'widgets/link_status.dart';
import 'widgets/mock_banner.dart';
class AppShell extends StatefulWidget {
const AppShell({
super.key,
required this.boundary,
required this.usb,
required this.linkTest,
required this.themes,
});
final Boundary boundary;
final UsbLink usb;
final LinkTest linkTest;
final Themes themes;
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final _scaffold = GlobalKey<ScaffoldState>();
// Captures runs its own navigator (list → checklist), so the menu stays reachable
// inside it and the run survives switching views.
final _captures = GlobalKey<NavigatorState>();
final Set<ShellView> _built = {shellView.value};
void _go(ShellView v) {
shellView.value = v;
_scaffold.currentState?.closeDrawer();
}
Widget _page(ShellView v) {
final b = widget.boundary;
return switch (v) {
ShellView.dash => DashPage(boundary: b, themes: widget.themes),
ShellView.signals => SignalsPage(boundary: b, usb: widget.usb),
ShellView.captures => Navigator(
key: _captures,
onGenerateRoute: (_) => MaterialPageRoute<void>(
builder: (_) => ChecklistsPage(boundary: b),
),
),
ShellView.camera => CamViewPage(host: Uri.parse(b.url).host),
ShellView.linkTest => LinkTestPage(
test: widget.linkTest,
host: Uri.parse(b.url).host,
restoreBinding: () => widget.usb.bindFor(Uri.parse(b.url)),
),
ShellView.settings => SettingsPage(
boundary: b,
usb: widget.usb,
themes: widget.themes,
),
};
}
static const _kept = {ShellView.dash, ShellView.signals, ShellView.captures};
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: shellView,
builder: (context, current, _) {
_built.add(current);
return PopScope(
// Back: inside Captures first, then to the landing view, then out of the app.
canPop:
current == landingView &&
!(_captures.currentState?.canPop() ?? false),
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (current == ShellView.captures &&
(_captures.currentState?.canPop() ?? false)) {
_captures.currentState!.pop();
} else {
shellView.value = landingView;
}
},
child: ShellScope(
openMenu: () => _scaffold.currentState?.openDrawer(),
child: Scaffold(
key: _scaffold,
drawerEdgeDragWidth: 32,
drawer: _Menu(
boundary: widget.boundary,
current: current,
go: _go,
// From the shell's context: the drawer's is gone once it closes.
power: () {
_scaffold.currentState?.closeDrawer();
orinPowerOptions(context, widget.boundary);
},
),
body: ListenableBuilder(
listenable: widget.boundary,
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!)),
],
),
child: IndexedStack(
index: ShellView.values.indexOf(current),
children: [
for (final v in ShellView.values)
// Kept views, once built, stay built; the rest exist only while shown.
if ((_kept.contains(v) && _built.contains(v)) ||
v == current)
_page(v)
else
const SizedBox.shrink(),
],
),
),
),
),
);
},
);
}
}
class _Menu extends StatelessWidget {
const _Menu({
required this.boundary,
required this.current,
required this.go,
required this.power,
});
final Boundary boundary;
final ShellView current;
final void Function(ShellView) go;
final VoidCallback power;
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
Widget item(ShellView v, IconData icon, String label) => ListTile(
leading: Icon(icon),
title: Text(label),
selected: v == current,
selectedColor: p.accent,
selectedTileColor: p.accent.withValues(alpha: 0.12),
onTap: () => go(v),
);
Widget heading(String t) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
t,
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: p.subdued),
),
);
return Drawer(
backgroundColor: p.surface,
child: SafeArea(
child: ListenableBuilder(
listenable: boundary,
builder: (context, _) {
final b = boundary;
final host = b.hello?['host'] as String?;
return ListView(
children: [
// The link, always: tapping it goes to Settings, where the address is.
InkWell(
onTap: () => go(ShellView.settings),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Yukart',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
LinkChip(boundary: b),
const SizedBox(height: 4),
Text(host ?? b.url, style: TextStyle(color: p.subdued)),
],
),
),
),
const Divider(),
item(ShellView.dash, Icons.speed, 'Dash'),
item(ShellView.signals, Icons.list_alt, 'Signals'),
item(ShellView.captures, Icons.checklist, 'Captures'),
const Divider(),
heading('Tools'),
item(ShellView.camera, Icons.videocam, 'Camera'),
item(ShellView.linkTest, Icons.network_check, 'Link test'),
ListTile(
leading: const Icon(Icons.power_settings_new),
title: const Text('Orin power options'),
subtitle:
b.state == LinkState.up && b.hello?['boot_id'] is String
? null
: Text(
'needs the link up',
style: TextStyle(color: p.subdued),
),
enabled:
b.state == LinkState.up && b.hello?['boot_id'] is String,
onTap: power,
),
const Divider(),
item(ShellView.settings, Icons.settings, 'Settings'),
],
);
},
),
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
// What a page needs from the shell (lib/shell.dart), without importing it: open the
// menu, switch view. Pages import this; the shell imports the pages.
import 'package:flutter/material.dart';
/// Every view the menu offers. Tools are the rarely used ones (pirate-station-aide's pattern).
enum ShellView { dash, signals, captures, camera, linkTest, settings }
/// The landing view, set at build time: `--dart-define=YUKART_HOME=dash` for the car,
/// `signals` (the default, for now) while developing.
ShellView get landingView => switch (const String.fromEnvironment(
'YUKART_HOME',
defaultValue: 'signals',
)) {
'dash' => ShellView.dash,
_ => ShellView.signals,
};
/// The view on screen. The shell listens to it; anything may set it (the link-test hook does).
final shellView = ValueNotifier<ShellView>(landingView);
class ShellScope extends InheritedWidget {
const ShellScope({super.key, required this.openMenu, required super.child});
final VoidCallback openMenu;
static ShellScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ShellScope>();
@override
bool updateShouldNotify(ShellScope old) => false;
}
/// The menu button every page puts where a back arrow would be. The menu also opens
/// with a swipe in from the left edge.
class MenuButton extends StatelessWidget {
const MenuButton({super.key, this.color});
final Color? color;
@override
Widget build(BuildContext context) => IconButton(
tooltip: 'Menu',
icon: Icon(Icons.menu, color: color),
onPressed: ShellScope.maybeOf(context)?.openMenu,
);
}
+71
View File
@@ -0,0 +1,71 @@
// The link to the Orin at a glance: the state chip, and the details behind it.
import 'package:flutter/material.dart';
import '../link/boundary.dart';
import '../link/net.dart';
import '../theme/palette.dart';
/// CONNECTED / CONNECTING / DOWN in good / caution / alarm.
class LinkChip extends StatelessWidget {
const LinkChip({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
final (color, label) = switch (boundary.state) {
LinkState.up => (p.good, 'CONNECTED'),
LinkState.connecting => (p.caution, 'CONNECTING'),
LinkState.down => (p.alarm, 'DOWN'),
};
return Chip(
label: Text(
label,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
backgroundColor: color.withValues(alpha: 0.3),
side: BorderSide.none,
visualDensity: VisualDensity.compact,
);
}
}
/// Address, gateway, last-message age, clock offset, USB link. Fixed-width numbers,
/// padded with figure spaces (U+2007, digit-wide): nothing reflows as a value gains a digit.
class LinkDetails extends StatelessWidget {
const LinkDetails({super.key, required this.boundary, required this.usb});
final Boundary boundary;
final UsbLink usb;
@override
Widget build(BuildContext context) {
final b = boundary;
String pad(String v, int n) => v.padLeft(n, ' ');
final age = b.lastMessage == null
? pad('—', 4)
: pad(
'${DateTime.now().difference(b.lastMessage!).inMilliseconds.clamp(0, 9999)}',
4,
);
final offset = b.clockOffset == null
? pad('—', 6)
: pad((b.clockOffset!.inMilliseconds / 1000).toStringAsFixed(1), 6);
const fixed = TextStyle(fontFeatures: [FontFeature.tabularFigures()]);
return Wrap(
spacing: 16,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(b.url, style: const TextStyle(fontFamily: 'monospace')),
Text(b.detail),
Text('last msg $age ms', style: fixed),
Text('Orin−tablet clock $offset s', style: fixed),
if (usb.supported)
Text(
'USB link ${usb.up ? 'up' : 'absent'}${usb.bound ? ', bound' : ''}',
),
],
);
}
}
+28
View File
@@ -0,0 +1,28 @@
// Mock data must look like mock data: on the surface of every page, not in a menu.
import 'package:flutter/material.dart';
import '../theme/palette.dart';
class MockBanner extends StatelessWidget {
const MockBanner({super.key});
@override
Widget build(BuildContext context) {
final p = AppTheme.of(context);
return Container(
color: p.caution,
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: p.onFill(p.caution),
fontWeight: FontWeight.w900,
fontSize: 20,
letterSpacing: 2,
),
),
);
}
}
+2 -2
View File
@@ -1,9 +1,9 @@
// An on/off lamp in the boxed pattern (lib/widgets/stat_box.dart): the label is the // An on/off lamp in the boxed pattern (lib/widgets/stat_box.dart): the label is the
// lamp. Takes a [Reading] (lib/reading.dart) so missing and stale draw as they do elsewhere. // lamp. Takes a [Reading] (lib/link/reading.dart) so missing and stale draw as they do elsewhere.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../reading.dart'; import '../link/reading.dart';
import '../theme/palette.dart'; import '../theme/palette.dart';
/// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground /// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground
+1 -1
View File
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:display/boundary.dart'; import 'package:display/link/boundary.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// A minimal stand-in for svc-gw: hello, then whatever the test sends. /// A minimal stand-in for svc-gw: hello, then whatever the test sends.
+1 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:display/checklist.dart'; import 'package:display/pages/checklist.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
Checklist sample() => Checklist.fromJson({ Checklist sample() => Checklist.fromJson({