Skip to content

Commit 7ec66d7

Browse files
committed
mobile: live sparklines on data rows + tank sort
- Sparkline widget (auto-scaled trend line) shown next to SOG, depth, water temp, wind, and each tank in the drawer. Values accumulate into rolling per-metric history buffers in BoatState as they arrive (last 150 samples) — live trend, no cloud data client needed. - Sort tanks with the web app's tankSort order (freshwater first, then fwd/mid/aft, main later; ties by name). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016m9NjsSfBSx1RgN3fpnx7e
1 parent bcb4b81 commit 7ec66d7

4 files changed

Lines changed: 159 additions & 34 deletions

File tree

mobile/lib/boat_state.dart

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,21 @@ class BoatState extends ChangeNotifier {
3333
String status = 'Starting…';
3434
DateTime? lastUpdate;
3535

36+
// Rolling live-value history for sparklines, keyed by metric
37+
// (depth/seatemp/sog/wind/tank:<name>). Live-accumulated as values arrive.
38+
final Map<String, List<double>> history = {};
39+
static const int _histCap = 150;
40+
3641
bool get connected => status.startsWith('Connected');
3742

43+
List<double>? spark(String key) => history[key];
44+
45+
void _push(String key, double v) {
46+
final list = history.putIfAbsent(key, () => <double>[]);
47+
list.add(v);
48+
if (list.length > _histCap) list.removeAt(0);
49+
}
50+
3851
void setAis(List<AisBoat> boats) {
3952
aisBoats = boats;
4053
notifyListeners();
@@ -77,7 +90,12 @@ class BoatState extends ChangeNotifier {
7790
}
7891
if (seakeeperPower != null) this.seakeeperPower = seakeeperPower;
7992
if (seakeeperProgress != null) this.seakeeperProgress = seakeeperProgress;
80-
if (tanks != null) this.tanks = tanks;
93+
if (tanks != null) {
94+
this.tanks = tanks;
95+
for (final t in tanks) {
96+
_push('tank:${t.name}', t.level);
97+
}
98+
}
8199
if (acVolts != null) this.acVolts = acVolts;
82100
if (acWatts != null) this.acWatts = acWatts;
83101
notifyListeners();
@@ -99,12 +117,24 @@ class BoatState extends ChangeNotifier {
99117
double? windAngleDeg,
100118
}) {
101119
if (position != null) this.position = position;
102-
if (speedKn != null) this.speedKn = speedKn;
120+
if (speedKn != null) {
121+
this.speedKn = speedKn;
122+
_push('sog', speedKn);
123+
}
103124
if (cogDeg != null) this.cogDeg = cogDeg;
104125
if (headingDeg != null) this.headingDeg = headingDeg;
105-
if (depthFt != null) this.depthFt = depthFt;
106-
if (seaTempF != null) this.seaTempF = seaTempF;
107-
if (windSpeedKn != null) this.windSpeedKn = windSpeedKn;
126+
if (depthFt != null) {
127+
this.depthFt = depthFt;
128+
_push('depth', depthFt);
129+
}
130+
if (seaTempF != null) {
131+
this.seaTempF = seaTempF;
132+
_push('seatemp', seaTempF);
133+
}
134+
if (windSpeedKn != null) {
135+
this.windSpeedKn = windSpeedKn;
136+
_push('wind', windSpeedKn);
137+
}
108138
if (windAngleDeg != null) this.windAngleDeg = windAngleDeg;
109139
lastUpdate = DateTime.now();
110140
notifyListeners();

mobile/lib/data_drawer.dart

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:flutter/material.dart';
22

33
import 'boat_state.dart';
4+
import 'sparkline.dart';
45

56
/// The dashboard: all the boat readouts that used to be overlaid on the chart,
67
/// grouped into sections. Opened from the map's dashboard button. Rebuilds live
@@ -60,14 +61,16 @@ class DataDrawer extends StatelessWidget {
6061
: '${pos.latitude.toStringAsFixed(5)}, '
6162
'${pos.longitude.toStringAsFixed(5)}',
6263
),
63-
_Row('SOG', _fmt(state.speedKn, 'kn')),
64+
_Row('SOG', _fmt(state.speedKn, 'kn'), spark: state.spark('sog')),
6465
_Row('COG', _fmt(state.cogDeg, '°', digits: 0)),
6566
_Row('Heading', _fmt(state.headingDeg, '°', digits: 0)),
6667
const SizedBox(height: 16),
6768
const _Section('Environment'),
68-
_Row('Depth', _fmt(state.depthFt, 'ft')),
69-
_Row('Water temp', _fmt(state.seaTempF, '°F')),
70-
_Row('Wind', wind),
69+
_Row('Depth', _fmt(state.depthFt, 'ft'),
70+
spark: state.spark('depth')),
71+
_Row('Water temp', _fmt(state.seaTempF, '°F'),
72+
spark: state.spark('seatemp')),
73+
_Row('Wind', wind, spark: state.spark('wind')),
7174
if (state.spotZeroFwGph != null || state.spotZeroSwGph != null) ...[
7275
const SizedBox(height: 16),
7376
const _Section('Watermaker'),
@@ -80,7 +83,8 @@ class DataDrawer extends StatelessWidget {
8083
const SizedBox(height: 16),
8184
const _Section('Tanks'),
8285
for (final t in state.tanks)
83-
_Row(t.name, '${t.level.toStringAsFixed(0)}%'),
86+
_Row(t.name, '${t.level.toStringAsFixed(0)}%',
87+
spark: state.spark('tank:${t.name}')),
8488
],
8589
if (state.seakeeperStabilizing != null ||
8690
state.acVolts != null) ...[
@@ -117,29 +121,32 @@ class _Section extends StatelessWidget {
117121
}
118122

119123
class _Row extends StatelessWidget {
120-
const _Row(this.label, this.value);
124+
const _Row(this.label, this.value, {this.spark});
121125
final String label;
122126
final String value;
127+
final List<double>? spark;
123128
@override
124-
Widget build(BuildContext context) => Padding(
125-
padding: const EdgeInsets.symmetric(vertical: 6),
126-
child: Row(
127-
crossAxisAlignment: CrossAxisAlignment.baseline,
128-
textBaseline: TextBaseline.alphabetic,
129-
children: [
130-
SizedBox(
131-
width: 96,
132-
child: Text(label,
133-
style: const TextStyle(color: Colors.white60, fontSize: 13)),
134-
),
135-
Expanded(
136-
child: Text(value,
137-
style: const TextStyle(
138-
color: Colors.white,
139-
fontSize: 16,
140-
fontWeight: FontWeight.w600)),
141-
),
142-
],
143-
),
144-
);
129+
Widget build(BuildContext context) {
130+
final s = spark;
131+
return Padding(
132+
padding: const EdgeInsets.symmetric(vertical: 6),
133+
child: Row(
134+
children: [
135+
SizedBox(
136+
width: 96,
137+
child: Text(label,
138+
style: const TextStyle(color: Colors.white60, fontSize: 13)),
139+
),
140+
Expanded(
141+
child: Text(value,
142+
style: const TextStyle(
143+
color: Colors.white,
144+
fontSize: 16,
145+
fontWeight: FontWeight.w600)),
146+
),
147+
if (s != null && s.length >= 2) Sparkline(data: s),
148+
],
149+
),
150+
);
151+
}
145152
}

mobile/lib/sparkline.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'dart:math' show max, min;
2+
3+
import 'package:flutter/material.dart';
4+
5+
/// Tiny trend line for a rolling series of values (auto-scaled to its own
6+
/// min/max). Used next to the drawer readouts. Renders nothing until there are
7+
/// at least 2 points.
8+
class Sparkline extends StatelessWidget {
9+
const Sparkline({
10+
super.key,
11+
required this.data,
12+
this.color = Colors.white70,
13+
this.width = 60,
14+
this.height = 20,
15+
});
16+
17+
final List<double> data;
18+
final Color color;
19+
final double width;
20+
final double height;
21+
22+
@override
23+
Widget build(BuildContext context) {
24+
return SizedBox(
25+
width: width,
26+
height: height,
27+
child: (data.length < 2)
28+
? null
29+
: CustomPaint(painter: _SparkPainter(data, color)),
30+
);
31+
}
32+
}
33+
34+
class _SparkPainter extends CustomPainter {
35+
_SparkPainter(this.data, this.color);
36+
final List<double> data;
37+
final Color color;
38+
39+
@override
40+
void paint(Canvas canvas, Size size) {
41+
var lo = data.reduce(min);
42+
var hi = data.reduce(max);
43+
if (hi - lo < 1e-9) hi = lo + 1; // flat series → avoid divide-by-zero
44+
final path = Path();
45+
for (var i = 0; i < data.length; i++) {
46+
final x = i / (data.length - 1) * size.width;
47+
final y = size.height - (data[i] - lo) / (hi - lo) * size.height;
48+
if (i == 0) {
49+
path.moveTo(x, y);
50+
} else {
51+
path.lineTo(x, y);
52+
}
53+
}
54+
canvas.drawPath(
55+
path,
56+
Paint()
57+
..color = color
58+
..style = PaintingStyle.stroke
59+
..strokeWidth = 1.5
60+
..strokeJoin = StrokeJoin.round,
61+
);
62+
}
63+
64+
@override
65+
bool shouldRepaint(covariant _SparkPainter old) =>
66+
old.data != data || old.color != color;
67+
}

mobile/lib/viam_connection.dart

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ class ViamConnection {
8181
_spotZeroFwName = _discoverSensorByName(robot, 'spotzero-fw', '');
8282
_spotZeroSwName = _discoverSensorByName(robot, 'spotzero-sw', '');
8383
_seakeeperName = _discoverSensorByName(robot, 'seakeeper', '');
84-
_tankNames = _discoverSensorsWhere(
85-
robot, (n) => n.contains('fuel') || n.contains('freshwater'));
84+
_tankNames = _tankSort(_discoverSensorsWhere(
85+
robot, (n) => n.contains('fuel') || n.contains('freshwater')));
8686
_acPowerNames =
8787
_discoverSensorsWhere(robot, (n) => RegExp(r'ac-\d-\d$').hasMatch(n));
8888
final cameras = _discoverCameras(robot);
@@ -135,6 +135,27 @@ class ViamConnection {
135135

136136
bool _boolish(dynamic v) => v == true || (v is num && v != 0);
137137

138+
/// Port of the web app's tankSort: freshwater first, then fwd/mid/aft, main
139+
/// later; ties broken by name.
140+
List<String> _tankSort(List<String> names) {
141+
int score(String raw) {
142+
final n = raw.toLowerCase();
143+
var s = 0;
144+
if (n.contains('freshwater')) s -= 10000;
145+
if (n.contains('fwd')) s -= 1000;
146+
if (n.contains('mid')) s -= 500;
147+
if (n.contains('aft')) s -= 250;
148+
if (n.contains('main')) s += 50;
149+
return s;
150+
}
151+
152+
return List.of(names)
153+
..sort((a, b) {
154+
final d = score(a) - score(b);
155+
return d != 0 ? d : a.toLowerCase().compareTo(b.toLowerCase());
156+
});
157+
}
158+
138159
/// Camera components on the robot, sorted, with the web app's filtered-camera
139160
/// rule: drop "<name>" when a "<name>-filtered" sibling exists (prefer the
140161
/// filtered feed). (chartplotter-hide needs the machine config; not yet done.)

0 commit comments

Comments
 (0)