Skip to content

Commit 0cc24a8

Browse files
committed
mobile: show active waypoint distance + ETA (and fix route sensor keys)
The route sensor readings use spaced/capitalised keys ("Destination Latitude", …), but the mobile poll read camelCase, so the destination flag never appeared. Fix the keys and also read "Distance to Waypoint" and "Waypoint Closing Velocity". - boat_state: carry wpDistanceM / wpClosingMs; derive nm, ETA minutes, and wall-clock arrival (distance / closing velocity, matching the web app). - drawer: Navigation section gains Next WP + ETA rows while navigating. - map: top-center pill with distance + minutes to the next waypoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016m9NjsSfBSx1RgN3fpnx7e
1 parent d18fdd5 commit 0cc24a8

4 files changed

Lines changed: 111 additions & 7 deletions

File tree

mobile/lib/boat_state.dart

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ class BoatState extends ChangeNotifier {
2727
List<AisBoat> aisBoats = const [];
2828
List<String> cameraNames = const [];
2929
LatLng? destination; // active route destination (from the `route` sensor)
30+
double? wpDistanceM; // distance to next waypoint, metres
31+
double? wpClosingMs; // closing velocity toward the waypoint, m/s
3032
// Which component each reading came from (for the drawer's Sources section).
3133
Map<String, String?> sources = const {};
3234
String windInfo = 'off'; // wind-overlay fetch state, shown in Debug
@@ -40,6 +42,28 @@ class BoatState extends ChangeNotifier {
4042

4143
bool get connected => status.startsWith('Connected');
4244

45+
/// True when there's an active waypoint to navigate to.
46+
bool get navigating => (wpDistanceM ?? 0) > 0;
47+
48+
/// Distance to the next waypoint in nautical miles.
49+
double? get wpDistanceNm =>
50+
wpDistanceM == null ? null : wpDistanceM! * 0.000539957;
51+
52+
/// Minutes to the next waypoint at the current closing velocity, or null when
53+
/// not closing (stationary / opening). Mirrors the web app's calc.
54+
double? get wpEtaMinutes =>
55+
(wpDistanceM != null && wpClosingMs != null && wpClosingMs! > 0)
56+
? wpDistanceM! / wpClosingMs! / 60
57+
: null;
58+
59+
/// Wall-clock arrival time, or null when ETA is unknown.
60+
DateTime? get wpEta {
61+
final m = wpEtaMinutes;
62+
return m == null
63+
? null
64+
: DateTime.now().add(Duration(seconds: (m * 60).round()));
65+
}
66+
4367
/// Last ~60 values for the inline sparkline glance.
4468
List<double> spark(String key) {
4569
final l = history[key];
@@ -63,8 +87,14 @@ class BoatState extends ChangeNotifier {
6387
notifyListeners();
6488
}
6589

66-
void setDestination(LatLng? d) {
67-
destination = d;
90+
void setRoute({
91+
LatLng? destination,
92+
double? wpDistanceM,
93+
double? wpClosingMs,
94+
}) {
95+
this.destination = destination;
96+
this.wpDistanceM = wpDistanceM;
97+
this.wpClosingMs = wpClosingMs;
6898
notifyListeners();
6999
}
70100

mobile/lib/data_drawer.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ class DataDrawer extends StatelessWidget {
2626
return _GraphSpec(metric, label, unit, digits);
2727
}
2828

29+
String _eta(BoatState s) {
30+
final m = s.wpEtaMinutes;
31+
if (m == null) return '—';
32+
final t = s.wpEta!;
33+
final hh = t.hour.toString().padLeft(2, '0');
34+
final mm = t.minute.toString().padLeft(2, '0');
35+
return '${m.toStringAsFixed(0)} min ($hh:$mm)';
36+
}
37+
2938
String _seakeeper(BoatState s) {
3039
if (s.seakeeperStabilizing == true) {
3140
final p = s.seakeeperProgress;
@@ -81,6 +90,14 @@ class DataDrawer extends StatelessWidget {
8190
history: history),
8291
_Row('COG', _fmt(state.cogDeg, '°', digits: 0)),
8392
_Row('Heading', _fmt(state.headingDeg, '°', digits: 0)),
93+
if (state.navigating) ...[
94+
_Row(
95+
'Next WP',
96+
state.wpDistanceNm == null
97+
? '—'
98+
: '${state.wpDistanceNm!.toStringAsFixed(2)} nm'),
99+
_Row('ETA', _eta(state)),
100+
],
84101
const SizedBox(height: 16),
85102
const _Section('Environment'),
86103
_Row('Depth', _fmt(state.depthFt, 'ft'),

mobile/lib/map_screen.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,17 @@ class _MapScreenState extends State<MapScreen> {
369369
),
370370
),
371371
),
372+
// Top-center: glanceable next-waypoint distance + ETA while navigating.
373+
if (s.navigating)
374+
SafeArea(
375+
child: Align(
376+
alignment: Alignment.topCenter,
377+
child: Padding(
378+
padding: const EdgeInsets.only(top: 12),
379+
child: _EtaPill(state: s),
380+
),
381+
),
382+
),
372383
// Top-right: map controls (layer switcher + dashboard opener).
373384
SafeArea(
374385
child: Align(
@@ -524,6 +535,45 @@ class _BoatMarker extends StatelessWidget {
524535
}
525536
}
526537

538+
/// Top-center pill shown while a route is active: distance to the next
539+
/// waypoint and estimated time, from the `route` sensor.
540+
class _EtaPill extends StatelessWidget {
541+
const _EtaPill({required this.state});
542+
final BoatState state;
543+
544+
@override
545+
Widget build(BuildContext context) {
546+
final nm = state.wpDistanceNm;
547+
final mins = state.wpEtaMinutes;
548+
final parts = <String>[
549+
if (nm != null) '${nm.toStringAsFixed(2)} nm',
550+
if (mins != null) '${mins.toStringAsFixed(0)} min',
551+
];
552+
if (parts.isEmpty) return const SizedBox.shrink();
553+
return Container(
554+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
555+
decoration: BoxDecoration(
556+
color: Colors.black.withValues(alpha: 0.6),
557+
borderRadius: BorderRadius.circular(20),
558+
),
559+
child: Row(
560+
mainAxisSize: MainAxisSize.min,
561+
children: [
562+
const Icon(Icons.flag, size: 14, color: Colors.purpleAccent),
563+
const SizedBox(width: 6),
564+
Text(
565+
parts.join(' · '),
566+
style: const TextStyle(
567+
color: Colors.white,
568+
fontSize: 14,
569+
fontWeight: FontWeight.w600),
570+
),
571+
],
572+
),
573+
);
574+
}
575+
}
576+
527577
/// Small pill in the top-left: a colour-coded dot plus the connection status.
528578
class _StatusChip extends StatelessWidget {
529579
const _StatusChip({required this.state});

mobile/lib/viam_connection.dart

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -343,11 +343,18 @@ class ViamConnection {
343343
if (routeName != null && _tickN % 5 == 0) {
344344
try {
345345
final r = await Sensor.fromRobot(robot, routeName).readings();
346-
final lat = r['destinationLatitude'];
347-
final lon = r['destinationLongitude'];
348-
state.setDestination((lat is num && lon is num)
349-
? LatLng(lat.toDouble(), lon.toDouble())
350-
: null);
346+
// Keys match the web app's route sensor (spaced/capitalised).
347+
final lat = r['Destination Latitude'];
348+
final lon = r['Destination Longitude'];
349+
final dist = r['Distance to Waypoint']; // metres
350+
final closing = r['Waypoint Closing Velocity']; // m/s
351+
state.setRoute(
352+
destination: (lat is num && lon is num)
353+
? LatLng(lat.toDouble(), lon.toDouble())
354+
: null,
355+
wpDistanceM: dist is num ? dist.toDouble() : null,
356+
wpClosingMs: closing is num ? closing.toDouble() : null,
357+
);
351358
} catch (_) {}
352359
}
353360

0 commit comments

Comments
 (0)