diff --git a/docs/impl/reports/LINK-TEST-2026-10-11.md b/docs/impl/reports/LINK-TEST-2026-10-11.md index 0a9c95b..f8978a2 100644 --- a/docs/impl/reports/LINK-TEST-2026-10-11.md +++ b/docs/impl/reports/LINK-TEST-2026-10-11.md @@ -6,7 +6,7 @@ date: 2026-10-11 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, -`lib/linktest.dart` + `tool/linktest_server.py`. +`lib/linktest.dart` (now `lib/pages/link_test.dart`) + `tool/linktest_server.py`. ## Short version diff --git a/lib/README.md b/lib/README.md index 7a24d1f..372c992 100644 --- a/lib/README.md +++ b/lib/README.md @@ -3,15 +3,21 @@ type: reference --- # 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 | 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 | -| `theme/` | `palette.dart`: the palette and `hudFont` | -| `widgets/` | Reusable display widgets, one per file, each fed values, never the boundary | +| `main.dart` | Startup: the link to the Orin, the theme, then the shell | +| `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 | +| `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 @@ -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 | | `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 | +| `MockBanner` | `widgets/mock_banner.dart` | "MOCK DATA — NOT FROM THE CAR" in caution, over every page while the gateway is mocking | Yukart | ## Conventions diff --git a/lib/actions/marks.dart b/lib/actions/marks.dart new file mode 100644 index 0000000..34f39dd --- /dev/null +++ b/lib/actions/marks.dart @@ -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 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, + ), + ], + ), + ); + }, + ); +} diff --git a/lib/actions/orin_power.dart b/lib/actions/orin_power.dart new file mode 100644 index 0000000..7c74563 --- /dev/null +++ b/lib/actions/orin_power.dart @@ -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 orinPowerOptions(BuildContext context, Boundary b) async { + final host = b.hello?['host'] ?? 'the Orin'; + final verb = await showDialog( + 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}', + ), + ), + ); +} diff --git a/lib/actions/server_url.dart b/lib/actions/server_url.dart new file mode 100644 index 0000000..07a62a1 --- /dev/null +++ b/lib/actions/server_url.dart @@ -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 editUrl(BuildContext context, Boundary b, UsbLink usb) async { + final c = TextEditingController(text: b.url); + final url = await showDialog( + 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); +} diff --git a/lib/boundary.dart b/lib/link/boundary.dart similarity index 100% rename from lib/boundary.dart rename to lib/link/boundary.dart diff --git a/lib/net.dart b/lib/link/net.dart similarity index 100% rename from lib/net.dart rename to lib/link/net.dart diff --git a/lib/reading.dart b/lib/link/reading.dart similarity index 100% rename from lib/reading.dart rename to lib/link/reading.dart diff --git a/lib/main.dart b/lib/main.dart index 99583a3..2809884 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,13 @@ -// Yukart display — starter. A text readout of whatever svc-gw sends, proving the -// boundary contract before anything is drawn (initial plan, step 1). - -import 'dart:math'; +// Yukart display: the tablet app. Starts the link to the Orin and hands the screen to +// the shell (lib/shell.dart). Code layout: lib/README.md. import 'package:flutter/material.dart'; -import 'boundary.dart'; -import 'camview.dart'; -import 'checklist.dart'; -import 'dash.dart'; -import 'linktest.dart'; -import 'net.dart'; +import 'link/boundary.dart'; +import 'link/net.dart'; +import 'pages/link_test.dart'; +import 'shell.dart'; +import 'shell_scope.dart'; import 'theme/palette.dart'; /// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`. @@ -21,7 +18,7 @@ const defaultUrl = String.fromEnvironment( Future main() async { WidgetsFlutterBinding.ensureInitialized(); - themes = await Themes.load(); + final themes = await Themes.load(); final usb = UsbLink(); await usb.bindFor(Uri.parse(defaultUrl)); final boundary = Boundary(defaultUrl)..start(); @@ -38,11 +35,7 @@ Future main() async { final host = at < 0 ? Uri.parse(boundary.url).host : request.substring(at + 1); - navigatorKey.currentState?.push( - MaterialPageRoute( - builder: (_) => linkTestPage(boundary, linkTest, usb), - ), - ); + shellView.value = ShellView.linkTest; await switch (mode) { 'dl' => linkTest.runDownload(host, label), 'bi' => linkTest.runBurstIdle(host, label), @@ -52,34 +45,27 @@ Future main() async { } 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(); if (pending != null) { WidgetsBinding.instance.addPostFrameCallback((_) => runLinkTest(pending)); } } -final navigatorKey = GlobalKey(); - -/// 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 { const YukartApp({ 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 Widget build(BuildContext context) { @@ -88,519 +74,17 @@ class YukartApp extends StatelessWidget { listenable: themes, builder: (context, _) => MaterialApp( title: 'Yukart', - navigatorKey: navigatorKey, debugShowCheckedModeBanner: false, theme: themes.material(), builder: (context, child) => AppTheme(palette: themes.current, child: child!), - home: ListenableBuilder( - listenable: Listenable.merge([boundary, usb]), - builder: (context, _) => - Readout(boundary: boundary, usb: usb, linkTest: linkTest), + home: AppShell( + boundary: boundary, + usb: usb, + 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( - builder: (_) => DashPage(boundary: b, themes: themes), - ), - ), - icon: const Icon(Icons.dashboard), - label: const Text('Dash'), - ), - TextButton.icon( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => linkTestPage(b, linkTest, usb), - ), - ), - icon: const Icon(Icons.speed), - label: const Text('Link test'), - ), - TextButton.icon( - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - 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( - 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 orinPowerOptions(BuildContext context, Boundary b) async { - final host = b.hello?['host'] ?? 'the Orin'; - final verb = await showDialog( - 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.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 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 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 editUrl(BuildContext context, Boundary b, UsbLink usb) async { - final c = TextEditingController(text: b.url); - final url = await showDialog( - 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); -} diff --git a/lib/camview.dart b/lib/pages/camera.dart similarity index 98% rename from lib/camview.dart rename to lib/pages/camera.dart index b735d86..b073b9e 100644 --- a/lib/camview.dart +++ b/lib/pages/camera.dart @@ -10,7 +10,8 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'theme/palette.dart'; +import '../shell_scope.dart'; +import '../theme/palette.dart'; const camviewPort = 7710; @@ -168,7 +169,7 @@ class _CamViewPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.of(context).background, - appBar: AppBar(title: const Text('Camera (test)')), + appBar: AppBar(leading: const MenuButton(), title: const Text('Camera (test)')), body: SafeArea( child: ListenableBuilder( listenable: cam, diff --git a/lib/checklist.dart b/lib/pages/checklist.dart similarity index 97% rename from lib/checklist.dart rename to lib/pages/checklist.dart index 04f5e2f..a006b51 100644 --- a/lib/checklist.dart +++ b/lib/pages/checklist.dart @@ -17,8 +17,9 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'boundary.dart'; -import 'theme/palette.dart'; +import '../link/boundary.dart'; +import '../shell_scope.dart'; +import '../theme/palette.dart'; const checklistAssets = 'assets/contracts/checklists/'; @@ -197,13 +198,13 @@ class ChecklistsPage extends StatelessWidget { future: loadChecklists(), builder: (context, snap) { 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; if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single); return Scaffold( - appBar: AppBar(title: const Text('Captures')), + appBar: AppBar(leading: const MenuButton(), title: const Text('Captures')), body: ListView( children: [ if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')), diff --git a/lib/dash.dart b/lib/pages/dash.dart similarity index 87% rename from lib/dash.dart rename to lib/pages/dash.dart index 344c4e0..882e98d 100644 --- a/lib/dash.dart +++ b/lib/pages/dash.dart @@ -5,14 +5,15 @@ import 'package:flutter/material.dart'; -import 'boundary.dart'; -import 'reading.dart'; -import 'theme/palette.dart'; -import 'widgets/accel_graph.dart'; -import 'widgets/gps_compass.dart'; -import 'widgets/stat_box.dart'; -import 'widgets/telltale.dart'; -import 'widgets/whiskey_mark.dart'; +import '../link/boundary.dart'; +import '../shell_scope.dart'; +import '../link/reading.dart'; +import '../theme/palette.dart'; +import '../widgets/accel_graph.dart'; +import '../widgets/gps_compass.dart'; +import '../widgets/stat_box.dart'; +import '../widgets/telltale.dart'; +import '../widgets/whiskey_mark.dart'; class DashPage extends StatelessWidget { const DashPage({super.key, required this.boundary, required this.themes}); @@ -47,20 +48,6 @@ class DashPage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, 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), Expanded( 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 { const _DashBar({required this.boundary, required this.themes}); final Boundary boundary; @@ -193,10 +180,7 @@ class _DashBar extends StatelessWidget { height: 40, child: Row( children: [ - IconButton( - onPressed: () => Navigator.of(context).maybePop(), - icon: Icon(Icons.arrow_back, color: p.subdued), - ), + MenuButton(color: p.subdued), Text(text, style: TextStyle(color: color, letterSpacing: 2)), const Spacer(), IconButton( diff --git a/lib/linktest.dart b/lib/pages/link_test.dart similarity index 99% rename from lib/linktest.dart rename to lib/pages/link_test.dart index b708040..40b3f72 100644 --- a/lib/linktest.dart +++ b/lib/pages/link_test.dart @@ -11,7 +11,8 @@ import 'dart:typed_data'; 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; @@ -419,7 +420,7 @@ class _LinkTestPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Link test')), + appBar: AppBar(leading: const MenuButton(), title: const Text('Link test')), body: ListenableBuilder( listenable: widget.test, builder: (context, _) { diff --git a/lib/pages/settings.dart b/lib/pages/settings.dart new file mode 100644 index 0000000..239cb4b --- /dev/null +++ b/lib/pages/settings.dart @@ -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=', + ), + ), + 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', + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/signals.dart b/lib/pages/signals.dart new file mode 100644 index 0000000..2c715d1 --- /dev/null +++ b/lib/pages/signals.dart @@ -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.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 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), + ), + ), + ], + ); + } +} diff --git a/lib/shell.dart b/lib/shell.dart new file mode 100644 index 0000000..5917eba --- /dev/null +++ b/lib/shell.dart @@ -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 createState() => _AppShellState(); +} + +class _AppShellState extends State { + final _scaffold = GlobalKey(); + // Captures runs its own navigator (list → checklist), so the menu stays reachable + // inside it and the run survives switching views. + final _captures = GlobalKey(); + final Set _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( + 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'), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/shell_scope.dart b/lib/shell_scope.dart new file mode 100644 index 0000000..685deeb --- /dev/null +++ b/lib/shell_scope.dart @@ -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(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(); + + @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, + ); +} diff --git a/lib/widgets/link_status.dart b/lib/widgets/link_status.dart new file mode 100644 index 0000000..3fa273e --- /dev/null +++ b/lib/widgets/link_status.dart @@ -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' : ''}', + ), + ], + ); + } +} diff --git a/lib/widgets/mock_banner.dart b/lib/widgets/mock_banner.dart new file mode 100644 index 0000000..8369659 --- /dev/null +++ b/lib/widgets/mock_banner.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/widgets/telltale.dart b/lib/widgets/telltale.dart index c71f65f..ab2354e 100644 --- a/lib/widgets/telltale.dart +++ b/lib/widgets/telltale.dart @@ -1,9 +1,9 @@ // 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 '../reading.dart'; +import '../link/reading.dart'; import '../theme/palette.dart'; /// An on/off lamp in the boxed pattern: the label is the lamp. Lit in foreground diff --git a/test/boundary_test.dart b/test/boundary_test.dart index eb6ffd9..34ee1a4 100644 --- a/test/boundary_test.dart +++ b/test/boundary_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:display/boundary.dart'; +import 'package:display/link/boundary.dart'; import 'package:flutter_test/flutter_test.dart'; /// A minimal stand-in for svc-gw: hello, then whatever the test sends. diff --git a/test/checklist_test.dart b/test/checklist_test.dart index 86a8896..9c97c49 100644 --- a/test/checklist_test.dart +++ b/test/checklist_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:display/checklist.dart'; +import 'package:display/pages/checklist.dart'; import 'package:flutter_test/flutter_test.dart'; Checklist sample() => Checklist.fromJson({