diff --git a/lib/boundary.dart b/lib/boundary.dart index 7056338..031fbe2 100644 --- a/lib/boundary.dart +++ b/lib/boundary.dart @@ -39,6 +39,10 @@ class Boundary extends ChangeNotifier { String detail = 'not started'; Map? hello; Map signals = {}; + + /// Last envelope of every signal this gateway has sent. A name here but not in + /// [signals] is missing right now โ€” distinct from null and stale. + final Map> seen = {}; DateTime? lastState; DateTime? lastMessage; @@ -75,6 +79,7 @@ class Boundary extends ChangeNotifier { /// Change the target; the current connection is dropped and the loop reconnects. void retarget(String newUrl) { url = newUrl; + seen.clear(); _ws?.close(); } @@ -144,6 +149,9 @@ class Boundary extends ChangeNotifier { } case 'state': signals = Map.from(m['signals'] as Map); + signals.forEach((k, v) { + if (v is Map) seen[k] = Map.from(v); + }); lastState = DateTime.now(); case 'ack': final id = m['id'] as String?; diff --git a/lib/main.dart b/lib/main.dart index a6886a9..022392e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ // 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'; @@ -142,7 +144,7 @@ class SignalList extends StatelessWidget { if (b.state != LinkState.up) { return const Center(child: Text('No link to the Orin')); } - if (b.signals.isEmpty) { + if (b.seen.isEmpty) { return Center( child: Text( 'Connected โ€” the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})', @@ -150,24 +152,41 @@ class SignalList extends StatelessWidget { ), ); } - final names = b.signals.keys.toList()..sort(); + // 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) SignalTile(name: n, envelope: Map.from(b.signals[n] as Map)), + 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), ], ); } } -/// Known-without-value, stale and fresh each look different: all three are normal in a car. +/// 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}); + 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'; @@ -227,6 +246,8 @@ 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; @@ -235,7 +256,8 @@ Future showMarkSheet(BuildContext context, Boundary b) { } return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + MediaQuery.of(ctx).viewInsets.bottom), + // 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), diff --git a/test/boundary_test.dart b/test/boundary_test.dart index 684e73c..eb6ffd9 100644 --- a/test/boundary_test.dart +++ b/test/boundary_test.dart @@ -91,4 +91,22 @@ void main() { await b.stop(); await gw.server.close(force: true); }); + + test('a signal that drops out stays known as missing', () async { + final gw = FakeGateway(); + await gw.start(); + final b = Boundary(gw.url)..start(); + await until(() => b.state == LinkState.up); + Map env(num v) => {'timestamp': 0, 'monotonic': 0, 'value': v, 'unit': 'x'}; + gw.sockets.first.add(jsonEncode({'t': 'state', 'seq': 1, 'signals': {'a': env(1), 'b': env(2)}})); + await until(() => b.signals.length == 2); + gw.sockets.first.add(jsonEncode({'t': 'state', 'seq': 2, 'signals': {'a': env(3)}})); + await until(() => b.signals.length == 1); + expect(b.seen.keys, containsAll(['a', 'b'])); + expect(b.signals.containsKey('b'), isFalse); + b.retarget(gw.url); + expect(b.seen, isEmpty); + await b.stop(); + await gw.server.close(force: true); + }); }