Files
app-flutter/lib/main.dart
T
mikkeli bf8a99fb23
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 10s
CI / Debug APK (push) Successful in 24s
Captures page: the recording checklist with one start/end button
Steps come from a bundled copy of contracts checklists/ (assets/contracts/,
refreshed by tool/sync-contracts.sh), not from Dart.

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

510 lines
15 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 'linktest.dart';
import 'net.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();
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>();
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) {
return MaterialApp(
title: 'Yukart',
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.teal,
brightness: Brightness.dark,
useMaterial3: true,
),
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: (_) => 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'),
),
],
),
),
),
);
}
}
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);
}