Skip to content

Commit 2de29fa

Browse files
eriedclaude
andcommitted
feat(dashboard): trip meter metric with graphed distance splits + HUD number
A car-odometer-style running distance meter, separate from the recorded TRIP. Counts while a wheel is connected (no recording needed), persists across app restarts and wheel power-downs, and is cleared only by its Reset button or by Stop All. Reuses the recorder's per-tick distance (GPS-primary, wheel-odometer fallback) so it costs nothing extra to run. - TripMeterRepository owns the accumulator, the split log, and DataStore persistence; TripMeterAccumulator holds the pure split-crossing logic (unit tested). At each 10 km / 10 mi mark it appends a split with segment time, avg / max speed, and battery at the mark. - New TRIP_METER dashboard metric routes to a tabbed detail view (Speed / Battery / Time graphs + split table) instead of the min/max/avg view, with a confirm-gated Reset. Added to knownDashboardMetrics so the layout picker lists it. - HUD: tripMeterKm on the wire (PROTOCOL_MINOR 12 to 13) plus the TRIP_METER overlay metric in both StudioMetric copies, so it is a HUD number too. - 18 strings localized across all 14 locales. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GADLyChheAoMX9dQbRgRnH
1 parent 9d17846 commit 2de29fa

39 files changed

Lines changed: 1538 additions & 5 deletions

File tree

app/src/main/java/com/eried/eucplanet/data/model/MetricCatalog.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ object MetricCatalog {
136136
sparkline = SparklineStyle.NONE, // monotonic counter
137137
supportsStats = false
138138
),
139+
MetricSpec(
140+
// Car-odometer-style running distance, counts while connected and
141+
// persists across restarts. Tapping opens the distance-split detail
142+
// view, not the generic min/max/avg one, so it needs no stat buffer.
143+
key = "TRIP_METER",
144+
labelRes = R.string.metric_chip_trip_meter,
145+
accent = AccentPurple,
146+
sparkline = SparklineStyle.NONE, // monotonic counter
147+
supportsStats = false
148+
),
139149

140150
// ---- Pool (already-buffered) ----
141151
MetricSpec(
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.eried.eucplanet.data.model
2+
3+
/**
4+
* One 10 km / 10 mi split record in the trip meter's log. Distances are stored
5+
* in km (the app's canonical distance unit); the detail view converts to the
6+
* rider's chosen unit at render time. All fields are cheap running accumulators,
7+
* captured the moment a boundary is crossed.
8+
*/
9+
data class TripMeterSplit(
10+
/** 1, 2, 3 ... in the order the boundaries were crossed. */
11+
val index: Int,
12+
/** Boundary distance in km (10, 20, 30 ... for a km rider; 16.09, 32.19 ...
13+
* for a mi rider whose boundary is every 10 mi). */
14+
val markDistanceKm: Float,
15+
/** Active (moving) time elapsed when this mark was reached, in ms. */
16+
val cumulativeMs: Long,
17+
/** Time for this segment alone (cumulativeMs minus the previous mark's), in ms. */
18+
val segmentMs: Long,
19+
/** Average speed over this segment in km/h (one step / segmentMs). */
20+
val segmentAvgKmh: Float,
21+
/** Peak speed seen during this segment in km/h (running max). */
22+
val segmentMaxKmh: Float,
23+
/** Battery percent sampled at the mark. -1 when unknown. */
24+
val batteryPctAtMark: Int,
25+
)
26+
27+
/**
28+
* Persisted running state of the car-odometer-style trip meter. Counts distance
29+
* while a wheel is connected (independent of recording) and is cleared only by a
30+
* manual reset or by Stop All. Distances are km; the UI converts to the rider's
31+
* unit.
32+
*/
33+
data class TripMeterState(
34+
/** Total distance since the last reset, in km. */
35+
val distanceKm: Float = 0f,
36+
/** Active (moving) time since the last reset, in ms. */
37+
val activeMs: Long = 0L,
38+
/** Wall-clock (epoch ms) the meter first started counting after a reset. 0 = never. */
39+
val startedAtMs: Long = 0L,
40+
/** Full split log, oldest first. Never pruned by the dashboard stats window. */
41+
val splits: List<TripMeterSplit> = emptyList(),
42+
) {
43+
/** Overall average speed in km/h over the active time. 0 when no active time yet. */
44+
val overallAvgKmh: Float
45+
get() = if (activeMs > 0L) (distanceKm / (activeMs / 3_600_000.0)).toFloat() else 0f
46+
}
47+
48+
/**
49+
* Pure split-accumulation core for the trip meter, free of Android types so it is
50+
* unit-testable on the JVM. Feed it per-tick deltas; it maintains the running
51+
* total and appends a [TripMeterSplit] each time distance crosses the next
52+
* multiple of [intervalKm], resetting the segment accumulators at every boundary.
53+
*
54+
* The in-progress (partial) segment is not a split yet; it lives in the running
55+
* [distanceKm] / [activeMs] the [snapshot] exposes.
56+
*/
57+
class TripMeterAccumulator(
58+
/** The split step in km (10 for a km rider, 10 mi -> ~16.09 for a mi rider). */
59+
var intervalKm: Float = 10f,
60+
) {
61+
var distanceKm: Double = 0.0
62+
private set
63+
var activeMs: Long = 0L
64+
private set
65+
var startedAtMs: Long = 0L
66+
private set
67+
68+
private val splits = mutableListOf<TripMeterSplit>()
69+
// Peak speed within the current (in-progress) segment; reset at each boundary.
70+
private var segmentMaxKmh: Float = 0f
71+
72+
/**
73+
* Advance the meter by one telemetry tick.
74+
*
75+
* @param distanceDeltaKm distance covered since the previous tick, in km (>= 0).
76+
* @param dtActiveMs active (moving) time since the previous tick, in ms (>= 0).
77+
* @param speedKmh current speed in km/h, folded into the segment running max.
78+
* @param batteryPct battery percent sampled this tick (used when a mark lands here).
79+
* @param nowMs wall clock, used only to stamp [startedAtMs] on the first motion.
80+
*/
81+
fun onTick(
82+
distanceDeltaKm: Float,
83+
dtActiveMs: Long,
84+
speedKmh: Float,
85+
batteryPct: Int,
86+
nowMs: Long,
87+
) {
88+
if (startedAtMs == 0L && (distanceDeltaKm > 0f || dtActiveMs > 0L)) startedAtMs = nowMs
89+
if (dtActiveMs > 0L) activeMs += dtActiveMs
90+
if (speedKmh > segmentMaxKmh) segmentMaxKmh = speedKmh
91+
if (distanceDeltaKm > 0f) distanceKm += distanceDeltaKm.toDouble()
92+
93+
val step = intervalKm.toDouble().coerceAtLeast(0.0001)
94+
// Usually crosses 0 or 1 boundary per tick; the loop also handles a rare
95+
// multi-boundary jump (a big GPS delta) without losing a split.
96+
while (distanceKm + 1e-6 >= (splits.size + 1) * step) {
97+
val index = splits.size + 1
98+
val markKm = index * step
99+
val prevCumMs = splits.lastOrNull()?.cumulativeMs ?: 0L
100+
val cumulativeMs = activeMs
101+
val segMs = (cumulativeMs - prevCumMs).coerceAtLeast(0L)
102+
val segAvg = if (segMs > 0L) (intervalKm / (segMs / 3_600_000.0)).toFloat() else 0f
103+
splits.add(
104+
TripMeterSplit(
105+
index = index,
106+
markDistanceKm = markKm.toFloat(),
107+
cumulativeMs = cumulativeMs,
108+
segmentMs = segMs,
109+
segmentAvgKmh = segAvg,
110+
segmentMaxKmh = segmentMaxKmh,
111+
batteryPctAtMark = batteryPct,
112+
)
113+
)
114+
segmentMaxKmh = 0f
115+
}
116+
}
117+
118+
/** Current running state (total + full split log). */
119+
fun snapshot(): TripMeterState =
120+
TripMeterState(
121+
distanceKm = distanceKm.toFloat(),
122+
activeMs = activeMs,
123+
startedAtMs = startedAtMs,
124+
splits = splits.toList(),
125+
)
126+
127+
/** Zero the total and clear the split log. */
128+
fun reset() {
129+
distanceKm = 0.0
130+
activeMs = 0L
131+
startedAtMs = 0L
132+
splits.clear()
133+
segmentMaxKmh = 0f
134+
}
135+
136+
/**
137+
* Reload from a persisted state (app restart). The in-progress segment's
138+
* running max isn't persisted, so it restarts at 0; the completed splits and
139+
* the totals carry over exactly.
140+
*/
141+
fun restore(state: TripMeterState) {
142+
distanceKm = state.distanceKm.toDouble()
143+
activeMs = state.activeMs
144+
startedAtMs = state.startedAtMs
145+
splits.clear()
146+
splits.addAll(state.splits)
147+
segmentMaxKmh = 0f
148+
}
149+
}

app/src/main/java/com/eried/eucplanet/data/model/WheelData.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ data class WheelData(
3232
* Distinct from externalGpsSpeedKmh (a paired box); merged in like lat/long
3333
* so an overlay / HUD element can show the phone GPS speed. */
3434
val gpsSpeedKmh: Float = -1f,
35+
/** Running trip-meter distance in km (the connect-scoped car odometer), or -1
36+
* when not merged in. Not wheel telemetry, so it stays -1 on the plain wheel
37+
* stream; the Overlay Studio / HUD merge it in like gpsSpeedKmh so an overlay
38+
* number can show it. */
39+
val tripMeterKm: Float = -1f,
3540
/** Phone IMU acceleration magnitude in g, 0 for trips recorded before this. */
3641
val gForce: Float = 0f,
3742
/** Phone IMU lateral acceleration in g (+right). 0 for trips recorded before this. */
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package com.eried.eucplanet.data.repository
2+
3+
import android.location.Location
4+
import android.util.Log
5+
import com.eried.eucplanet.ble.ConnectionState
6+
import com.eried.eucplanet.data.model.TripMeterAccumulator
7+
import com.eried.eucplanet.data.model.TripMeterState
8+
import com.eried.eucplanet.data.model.WheelData
9+
import com.eried.eucplanet.data.store.TripMeterStore
10+
import com.eried.eucplanet.util.Units
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.SupervisorJob
14+
import kotlinx.coroutines.delay
15+
import kotlinx.coroutines.flow.MutableStateFlow
16+
import kotlinx.coroutines.flow.StateFlow
17+
import kotlinx.coroutines.flow.asStateFlow
18+
import kotlinx.coroutines.flow.collectLatest
19+
import kotlinx.coroutines.launch
20+
import javax.inject.Inject
21+
import javax.inject.Singleton
22+
import kotlin.math.abs
23+
24+
/**
25+
* Car-odometer-style running trip meter, independent of the recording feature.
26+
*
27+
* Counts distance while a wheel is CONNECTED (not just while recording),
28+
* persists across app restarts / wheel power-downs, and is cleared only by a
29+
* manual [reset] or by Stop All (which also calls [reset]). Reuses the recorder's
30+
* per-tick distance approach (GPS-primary, wheel-odometer fallback) but never
31+
* touches the recorder's state, so counting keeps running with no trip active.
32+
*
33+
* At each 10 km / 10 mi boundary (following the rider's distance unit) it appends
34+
* a split via the pure [TripMeterAccumulator]. State is a single JSON blob in
35+
* [TripMeterStore]. WheelRepository injects TripRepository through dagger.Lazy to
36+
* break a DI cycle; we do the same for both so this repository can never form one.
37+
*/
38+
@Singleton
39+
class TripMeterRepository @Inject constructor(
40+
private val store: TripMeterStore,
41+
private val wheelRepositoryLazy: dagger.Lazy<WheelRepository>,
42+
private val tripRepositoryLazy: dagger.Lazy<TripRepository>,
43+
private val settingsRepository: SettingsRepository,
44+
) {
45+
companion object {
46+
private const val TAG = "TripMeterRepo"
47+
// Tick cadence while connected. Distance still comes from whatever GPS /
48+
// wheel-odometer delta accrued since the last tick, so a slow (idle-tier)
49+
// GPS stream just produces a larger delta on the tick a fresh fix lands.
50+
private const val TICK_INTERVAL_MS = 1000L
51+
// Persist at most this often for the running total (splits force a write).
52+
private const val PERSIST_INTERVAL_MS = 15_000L
53+
// GPS credibility gate, identical to the recorder's: skip jittery / huge jumps.
54+
private const val GPS_MIN_STEP_M = 0.5f
55+
private const val GPS_MAX_STEP_M = 200f
56+
private const val GPS_MAX_ACCURACY_M = 25f
57+
// Wheel-odometer fallback: accept only a small forward delta so a
58+
// power-cycle reset (negative) or a spurious jump can't inflate the total.
59+
private const val WHEEL_MAX_STEP_KM = 0.2f
60+
// Below this speed the wheel is treated as parked: distance may still be
61+
// logged (a slow GPS creep), but active time isn't, so avg speed stays sane.
62+
private const val MOVING_SPEED_KMH = 1f
63+
}
64+
65+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
66+
67+
private val accumulator = TripMeterAccumulator()
68+
69+
private val _state = MutableStateFlow(TripMeterState())
70+
val state: StateFlow<TripMeterState> = _state.asStateFlow()
71+
72+
/** Convenience for the HUD / Overlay Studio: current total distance in km. */
73+
val distanceKm: Float get() = _state.value.distanceKm
74+
75+
// Per-connection tick state, reset on each (re)connect.
76+
private var lastGpsPoint: Location? = null
77+
private var lastWheelOdoKm: Float = 0f
78+
private var lastTickMs: Long = 0L
79+
private var lastPersistMs: Long = 0L
80+
81+
init {
82+
// Restore the persisted meter first, then seed the accumulator from it.
83+
scope.launch {
84+
val restored = runCatching { store.load() }.getOrDefault(TripMeterState())
85+
accumulator.intervalKm = intervalKmFor(settingsRepository.get())
86+
accumulator.restore(restored)
87+
_state.value = accumulator.snapshot()
88+
}
89+
// Keep the split step in sync with the rider's distance unit.
90+
scope.launch {
91+
settingsRepository.settings.collect { accumulator.intervalKm = intervalKmFor(it) }
92+
}
93+
// Accumulate only while a wheel is connected; collectLatest cancels the
94+
// loop the moment the link drops (and flushes the total to storage).
95+
scope.launch {
96+
wheelRepository().connectionState.collectLatest { connState ->
97+
if (connState == ConnectionState.CONNECTED) {
98+
runAccumulationLoop()
99+
} else {
100+
resetTickState()
101+
persist(_state.value)
102+
}
103+
}
104+
}
105+
}
106+
107+
private fun wheelRepository(): WheelRepository = wheelRepositoryLazy.get()
108+
private fun tripRepository(): TripRepository = tripRepositoryLazy.get()
109+
110+
/** The split step in km: 10 in the rider's distance unit, stored as km. */
111+
private fun intervalKmFor(s: com.eried.eucplanet.data.model.AppSettings): Float =
112+
Units.distanceToKm(10f, Units.effectiveDistanceUnit(s))
113+
114+
private fun resetTickState() {
115+
lastGpsPoint = null
116+
lastWheelOdoKm = 0f
117+
lastTickMs = 0L
118+
}
119+
120+
private suspend fun runAccumulationLoop() {
121+
resetTickState()
122+
Log.i(TAG, "Trip meter accumulating (connected)")
123+
while (true) {
124+
val wheel = wheelRepository().wheelData.value
125+
val loc = tripRepository().currentLocation.value
126+
val now = System.currentTimeMillis()
127+
128+
val dKm = perTickDistanceKm(loc, wheel)
129+
// Wall time since the last tick, bounded so a long pause / clock jump
130+
// can't dump a phantom bucket of active time in one go.
131+
val dt = if (lastTickMs == 0L) 0L else (now - lastTickMs).coerceIn(0L, 5_000L)
132+
lastTickMs = now
133+
val speed = abs(wheel.speed)
134+
val moving = speed > MOVING_SPEED_KMH || dKm > 0f
135+
val dtActive = if (moving) dt else 0L
136+
137+
val before = _state.value.splits.size
138+
accumulator.onTick(dKm, dtActive, speed, wheel.batteryPercent, now)
139+
val snapshot = accumulator.snapshot()
140+
_state.value = snapshot
141+
142+
val splitAdded = snapshot.splits.size != before
143+
if (splitAdded || now - lastPersistMs >= PERSIST_INTERVAL_MS) {
144+
lastPersistMs = now
145+
persist(snapshot)
146+
}
147+
delay(TICK_INTERVAL_MS)
148+
}
149+
}
150+
151+
/**
152+
* Per-tick distance in km. GPS-primary: a credible fix pair (accuracy
153+
* <= 25 m, step in 0.5..200 m). When GPS gives nothing usable this tick, fall
154+
* back to the wheel's lifetime odometer delta (small forward steps only).
155+
* Mirrors the recorder's source order without depending on its state.
156+
*/
157+
private fun perTickDistanceKm(loc: Location?, wheel: WheelData): Float {
158+
if (loc != null) {
159+
val prev = lastGpsPoint
160+
val gpsKm = if (prev != null && loc.accuracy <= GPS_MAX_ACCURACY_M) {
161+
val stepM = prev.distanceTo(loc)
162+
if (stepM in GPS_MIN_STEP_M..GPS_MAX_STEP_M) stepM / 1000f else 0f
163+
} else 0f
164+
lastGpsPoint = loc
165+
if (gpsKm > 0f) {
166+
// Keep the odometer reference current so a later GPS gap doesn't
167+
// double-count the same ground via the fallback.
168+
if (wheel.totalDistance > 0f) lastWheelOdoKm = wheel.totalDistance
169+
return gpsKm
170+
}
171+
}
172+
// Wheel-odometer fallback.
173+
val odo = wheel.totalDistance
174+
if (odo > 0f) {
175+
val ref = lastWheelOdoKm
176+
lastWheelOdoKm = odo
177+
if (ref > 0f) {
178+
val d = odo - ref
179+
if (d > 0f && d <= WHEEL_MAX_STEP_KM) return d
180+
}
181+
}
182+
return 0f
183+
}
184+
185+
private fun persist(snapshot: TripMeterState) {
186+
scope.launch { runCatching { store.save(snapshot) } }
187+
}
188+
189+
/** Zero the total, clear the split log, and persist (fire-and-forget). Backs
190+
* the detail-view Reset button. */
191+
fun reset() {
192+
val snapshot = clearInMemory()
193+
persist(snapshot)
194+
Log.i(TAG, "Trip meter reset")
195+
}
196+
197+
/**
198+
* Reset and BLOCK until the cleared state is written to storage. The Stop All
199+
* teardown SIGKILLs the process the moment cleanup finishes, so the ordinary
200+
* fire-and-forget write in [reset] could lose the wipe to the kill. Callers on
201+
* that path use this so the next launch is guaranteed to start fresh.
202+
*/
203+
suspend fun resetAndPersist() {
204+
val snapshot = clearInMemory()
205+
runCatching { store.save(snapshot) }
206+
Log.i(TAG, "Trip meter reset (persisted)")
207+
}
208+
209+
private fun clearInMemory(): TripMeterState {
210+
accumulator.reset()
211+
resetTickState()
212+
val snapshot = accumulator.snapshot()
213+
_state.value = snapshot
214+
lastPersistMs = System.currentTimeMillis()
215+
return snapshot
216+
}
217+
}

0 commit comments

Comments
 (0)