Files
app-flutter/lib/main.dart
T
mikkeli 6a91909945
CI / Analyze & Test (push) Successful in 9s
CI / Debug APK (push) Successful in 16s
Link test: optional @host on the launch hook; summary script for the Orin's results
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
2026-10-11 15:58:43 +09:00

328 lines
12 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 '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 <label>[@<host>]` runs the test,
// on the gateway's host unless one is given.
Future<void> runLinkTest(String request) async {
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 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'),
};
final age = b.lastMessage == null ? '—' : '${DateTime.now().difference(b.lastMessage!).inMilliseconds} ms';
final offset = b.clockOffset == null ? '—' : '${(b.clockOffset!.inMilliseconds / 1000).toStringAsFixed(1)} s';
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'),
Text('Orin−tablet clock $offset'),
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'),
),
]),
),
),
);
}
}
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);
}