50081d5417
Connects to svc-gw over one WebSocket, heartbeats, treats timeout_ms of silence as a dead link, and retries commands under the same id until acked. Shows link state, signals with stale/null/missing told apart, the raw stream, a mock banner on the surface, and a Mark button that stamps the recording. Android: keeps the screen on and binds the process to the USB NCM network when the gateway is on it. CI mirrors android-stuff's host-mode runner setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
269 lines
9.5 KiB
Dart
269 lines
9.5 KiB
Dart
// Yukart display — starter. A text readout of whatever svc-gw sends, proving the
|
||
// boundary contract before anything is drawn (initial plan, step 1).
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import 'boundary.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();
|
||
runApp(YukartApp(boundary: boundary, usb: usb));
|
||
}
|
||
|
||
class YukartApp extends StatelessWidget {
|
||
const YukartApp({super.key, required this.boundary, required this.usb});
|
||
final Boundary boundary;
|
||
final UsbLink usb;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp(
|
||
title: 'Yukart',
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class Readout extends StatelessWidget {
|
||
const Readout({super.key, required this.boundary, required this.usb});
|
||
final Boundary boundary;
|
||
final UsbLink usb;
|
||
|
||
@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),
|
||
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});
|
||
final Boundary boundary;
|
||
final UsbLink usb;
|
||
|
||
@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' : ''}'),
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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.signals.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
|
||
textAlign: TextAlign.center,
|
||
),
|
||
);
|
||
}
|
||
final names = b.signals.keys.toList()..sort();
|
||
return ListView(
|
||
padding: const EdgeInsets.all(12),
|
||
children: [
|
||
for (final n in names) SignalTile(name: n, envelope: Map<String, dynamic>.from(b.signals[n] as Map)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Known-without-value, stale and fresh each look different: all three are normal in a car.
|
||
class SignalTile extends StatelessWidget {
|
||
const SignalTile({super.key, required this.name, required this.envelope});
|
||
final String name;
|
||
final Map<String, dynamic> envelope;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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,
|
||
builder: (ctx) {
|
||
void send(String text) {
|
||
if (text.trim().isEmpty) return;
|
||
b.command('mark', {'text': text.trim()});
|
||
Navigator.pop(ctx);
|
||
}
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + MediaQuery.of(ctx).viewInsets.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);
|
||
}
|