// Signals: every value the gateway sends, as a list, beside the raw traffic. The // development landing view (lib/shell_scope.dart). import 'package:flutter/material.dart'; import '../actions/marks.dart'; import '../actions/server_url.dart'; import '../link/boundary.dart'; import '../link/net.dart'; import '../shell_scope.dart'; import '../theme/palette.dart'; import '../widgets/link_status.dart'; class SignalsPage extends StatelessWidget { const SignalsPage({super.key, required this.boundary, required this.usb}); final Boundary boundary; final UsbLink usb; @override Widget build(BuildContext context) { final b = boundary; return ListenableBuilder( listenable: Listenable.merge([boundary, usb]), builder: (context, _) => Scaffold( appBar: AppBar( leading: const MenuButton(), title: const Text('Signals'), actions: [ LinkChip(boundary: b), const SizedBox(width: 12), ], ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Material( color: AppTheme.of(context).surface, child: InkWell( onTap: () => editUrl(context, b, usb), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), child: LinkDetails(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 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) { final p = AppTheme.of(context); if (missing) { return ListTile( title: Text( name, style: TextStyle(fontFamily: 'monospace', color: p.subdued), ), subtitle: Text( 'MISSING · not in the latest state', style: TextStyle(color: p.alarm), ), trailing: Text.rich( TextSpan( children: [ TextSpan( text: '?', style: TextStyle(fontSize: 34, color: p.subdued), ), TextSpan( text: ' ${envelope['unit'] ?? ''}', style: TextStyle(fontSize: 16, color: p.subdued), ), ], ), ), ); } 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 ? p.subdued : p.foreground, fontStyle: stale ? FontStyle.italic : null, ); return ListTile( title: Text(name, style: const TextStyle(fontFamily: 'monospace')), subtitle: Text( style: stale ? TextStyle(color: p.caution) : null, [ 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)', style: TextStyle(color: AppTheme.of(context).caution), ), for (final a in b.acks) Text( '${a.ok ? '✓' : '✗'} ${a.detail}', style: TextStyle( color: a.ok ? AppTheme.of(context).good : AppTheme.of(context).alarm, ), ), 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), ), ), ], ); } }