Keep dropped-out signals as MISSING rows; clear the nav bar in the mark sheet
A signal absent from the latest state now stays listed (from the last envelope seen on this gateway) and renders as missing, distinct from null and stale as BOUNDARY.md requires; rows no longer jump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
@@ -39,6 +39,10 @@ class Boundary extends ChangeNotifier {
|
|||||||
String detail = 'not started';
|
String detail = 'not started';
|
||||||
Map<String, dynamic>? hello;
|
Map<String, dynamic>? hello;
|
||||||
Map<String, dynamic> signals = {};
|
Map<String, dynamic> 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<String, Map<String, dynamic>> seen = {};
|
||||||
DateTime? lastState;
|
DateTime? lastState;
|
||||||
DateTime? lastMessage;
|
DateTime? lastMessage;
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ class Boundary extends ChangeNotifier {
|
|||||||
/// Change the target; the current connection is dropped and the loop reconnects.
|
/// Change the target; the current connection is dropped and the loop reconnects.
|
||||||
void retarget(String newUrl) {
|
void retarget(String newUrl) {
|
||||||
url = newUrl;
|
url = newUrl;
|
||||||
|
seen.clear();
|
||||||
_ws?.close();
|
_ws?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +149,9 @@ class Boundary extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
case 'state':
|
case 'state':
|
||||||
signals = Map<String, dynamic>.from(m['signals'] as Map);
|
signals = Map<String, dynamic>.from(m['signals'] as Map);
|
||||||
|
signals.forEach((k, v) {
|
||||||
|
if (v is Map) seen[k] = Map<String, dynamic>.from(v);
|
||||||
|
});
|
||||||
lastState = DateTime.now();
|
lastState = DateTime.now();
|
||||||
case 'ack':
|
case 'ack':
|
||||||
final id = m['id'] as String?;
|
final id = m['id'] as String?;
|
||||||
|
|||||||
+28
-6
@@ -1,6 +1,8 @@
|
|||||||
// Yukart display — starter. A text readout of whatever svc-gw sends, proving the
|
// Yukart display — starter. A text readout of whatever svc-gw sends, proving the
|
||||||
// boundary contract before anything is drawn (initial plan, step 1).
|
// boundary contract before anything is drawn (initial plan, step 1).
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'boundary.dart';
|
import 'boundary.dart';
|
||||||
@@ -142,7 +144,7 @@ class SignalList extends StatelessWidget {
|
|||||||
if (b.state != LinkState.up) {
|
if (b.state != LinkState.up) {
|
||||||
return const Center(child: Text('No link to the Orin'));
|
return const Center(child: Text('No link to the Orin'));
|
||||||
}
|
}
|
||||||
if (b.signals.isEmpty) {
|
if (b.seen.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Connected — the Orin is sending no vehicle state\n(source: ${b.hello?['source'] ?? '?'})',
|
'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(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
children: [
|
children: [
|
||||||
for (final n in names) SignalTile(name: n, envelope: Map<String, dynamic>.from(b.signals[n] as Map)),
|
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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
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;
|
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 Map<String, dynamic> envelope;
|
||||||
|
final bool missing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 v = envelope['value'];
|
||||||
final quality = envelope['quality'] as String? ?? 'ok';
|
final quality = envelope['quality'] as String? ?? 'ok';
|
||||||
final stale = quality != 'ok';
|
final stale = quality != 'ok';
|
||||||
@@ -227,6 +246,8 @@ Future<void> showMarkSheet(BuildContext context, Boundary b) {
|
|||||||
final custom = TextEditingController();
|
final custom = TextEditingController();
|
||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
void send(String text) {
|
void send(String text) {
|
||||||
if (text.trim().isEmpty) return;
|
if (text.trim().isEmpty) return;
|
||||||
@@ -235,7 +256,8 @@ Future<void> showMarkSheet(BuildContext context, Boundary b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
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: [
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
Text('Stamp into the recording', style: Theme.of(ctx).textTheme.titleMedium),
|
Text('Stamp into the recording', style: Theme.of(ctx).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -91,4 +91,22 @@ void main() {
|
|||||||
await b.stop();
|
await b.stop();
|
||||||
await gw.server.close(force: true);
|
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<String, dynamic> 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user