Files
app-flutter/lib/main.dart
T
mikkeli b083cf31dd
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 9s
CI / Debug APK (push) Successful in 17s
Dash page and smart-serow's palette, boxed widgets, attitude and G-meter
Ported from smart-serow's HUD: the four-colour palette with dark/bright variants
(same theme JSON format, loaded at runtime; YUKART_THEME picks one), StatBox and
StatBoxMain, WhiskeyMark and AccelGraph. The whole app takes its colours from the
palette. Dash: attitude, G-meter, RPM, GNSS speed, pedal, coolant and telltales,
each from its contracts signal; missing and stale draw in subdued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-13 23:25:28 +09:00

594 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import 'package:flutter/material.dart';
import 'boundary.dart';
import 'camview.dart';
import 'checklist.dart';
import 'dash.dart';
import 'linktest.dart';
import 'net.dart';
import 'theme/palette.dart';
/// Override for development, e.g. `--dart-define=YUKART_GW=ws://halogen.local:7700/`.
const defaultUrl = String.fromEnvironment(
'YUKART_GW',
defaultValue: 'ws://192.168.55.1:7700/',
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
themes = await Themes.load();
final usb = UsbLink();
await usb.bindFor(Uri.parse(defaultUrl));
final boundary = Boundary(defaultUrl)..start();
final linkTest = LinkTest(usb);
// Development hook: `am start ... --es linktest [dl:|bi:]<label>[@<host>]` runs the test
// (`dl:` the download series, `bi:` burst latency against idle gaps), on the gateway's host unless one is given.
Future<void> runLinkTest(String request) async {
final mode = request.startsWith('dl:') || request.startsWith('bi:')
? request.substring(0, 2)
: '';
if (mode.isNotEmpty) request = request.substring(3);
final at = request.indexOf('@');
final label = at < 0 ? request : request.substring(0, at);
final host = at < 0
? Uri.parse(boundary.url).host
: request.substring(at + 1);
navigatorKey.currentState?.push(
MaterialPageRoute<void>(
builder: (_) => linkTestPage(boundary, linkTest, usb),
),
);
await switch (mode) {
'dl' => linkTest.runDownload(host, label),
'bi' => linkTest.runBurstIdle(host, label),
_ => linkTest.run(host, label),
};
await usb.bindFor(Uri.parse(boundary.url));
}
usb.onLinkTest = runLinkTest;
runApp(YukartApp(boundary: boundary, usb: usb, linkTest: linkTest));
final pending = await usb.takeLinkTest();
if (pending != null) {
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 {
const YukartApp({
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) {
// Every page takes its colours from the palette; switching dark/bright rebuilds them all.
return ListenableBuilder(
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),
),
),
);
}
}
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) {
return Container(
color: Colors.amber.shade700,
padding: const EdgeInsets.symmetric(vertical: 6),
child: const Text(
'MOCK DATA — NOT FROM THE CAR',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black,
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 (color, label) = switch (b.state) {
LinkState.up => (Colors.green, 'CONNECTED'),
LinkState.connecting => (Colors.orange, 'CONNECTING'),
LinkState.down => (Colors.red, '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),
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: Colors.red.shade700),
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) {
if (missing) {
return ListTile(
title: Text(
name,
style: const TextStyle(fontFamily: 'monospace', color: Colors.grey),
),
subtitle: const Text(
'MISSING · not in the latest state',
style: TextStyle(color: Colors.redAccent),
),
trailing: Text.rich(
TextSpan(
children: [
const TextSpan(
text: '?',
style: TextStyle(fontSize: 34, color: Colors.grey),
),
TextSpan(
text: ' ${envelope['unit'] ?? ''}',
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
),
);
}
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 ? Colors.grey : null,
fontStyle: stale ? FontStyle.italic : null,
);
return ListTile(
title: Text(name, style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
[
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)'),
for (final a in b.acks)
Text(
'${a.ok ? '✓' : '✗'} ${a.detail}',
style: TextStyle(color: a.ok ? null : Colors.redAccent),
),
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);
}