// 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 '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 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.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) { 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 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); }