Skip to content

Commit 620579b

Browse files
eriedclaude
andcommitted
v0.6.3: imperial fixes, watch staleness gate, graph zoom + 5min cap
Imperial units (previously raw values shown to imperial users) - Persistent BLE notification: speed value + label switch to mph - Voice "Speed" report: value converts to mph - Voice "Temp" report: value converts to °F (string keeps generic "degrees") - Voice "Distance" report: value + label switch to miles via voice_trip_miles_fmt - Voice settings preview line follows the same imperial branch Watch (WatchApp.kt + WearBridge.kt + WheelService.kt) - MainScreen: speed arc, speed number, PWM bar/number, wheel battery all dash to "—" when the phone hasn't pushed for 3 s (process killed, BT range, force-stop) - DetailsScreen: headline speed + every detail row dash on the same gate, header flips to "Disconnected" - Phone-side WheelService.onDestroy now sends a farewell DataMap with connected=false so the watch flips state instantly on graceful service stop; the 3-s stale timer is the fallback for hard process kills Metric detail screen - Two-finger pinch-zoom on the chart (Load / Battery / Speed / Temp / Voltage / Current), 1..20x; zoom anchors around the pinch midpoint - Single-finger scrub still works inside the zoomed window - Double-tap or tap the "Reset" chip to snap the zoom back to 1x; 250 ms tween on both zoom and pan - Auto-reset on leaving the screen (local composable state) - History buffers in WheelRepository now time-windowed to exactly 5 min (was unbounded; chart drifted to 5m10s+ and the lists leaked memory the longer the wheel stayed connected) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33c70e7 commit 620579b

9 files changed

Lines changed: 279 additions & 56 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "com.eried.eucplanet"
2828
minSdk = 29
2929
targetSdk = 35
30-
versionCode = 41
31-
versionName = "0.6.2"
30+
versionCode = 42
31+
versionName = "0.6.3"
3232

3333
val buildStamp = SimpleDateFormat("yyMMdd.HHmm")
3434
.apply { timeZone = TimeZone.getTimeZone("UTC") }

app/src/main/java/com/eried/eucplanet/data/repository/WheelRepository.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class WheelRepository @Inject constructor(
5656
private const val TAG = "WheelRepo"
5757
private const val POLL_INTERVAL_MS = 250L
5858
private const val HISTORY_SAMPLE_INTERVAL_MS = 1000L
59+
// Hard 5-minute window on the metric history buffers. Without this,
60+
// each list grows unbounded at 1 Hz (memory leak) and the chart's
61+
// takeLast(300) shows ~5m10s instead of a clean 5m because the
62+
// sampler drifts. Time-bounding here makes the chart truly 5 min.
63+
private const val HISTORY_WINDOW_MS = 5 * 60 * 1000L
5964
// Re-request settings every N realtime polls to pick up external changes
6065
// (lock/unlock via InMotion app or physical button). 12 * 250ms = 3s.
6166
private const val SETTINGS_REFRESH_INTERVAL = 12
@@ -626,6 +631,12 @@ class WheelRepository @Inject constructor(
626631
ampsHist.add(MetricSample(now, d.current.absoluteValue))
627632
loadHist.add(MetricSample(now, d.pwm.absoluteValue))
628633
speedHist.add(MetricSample(now, d.speed.absoluteValue))
634+
// Drop anything older than the 5-min window from every
635+
// buffer in one pass. List.removeAll touches each list
636+
// once so this stays linear in buffer size.
637+
val cutoff = now - HISTORY_WINDOW_MS
638+
listOf(battHist, tempHist, voltHist, ampsHist, loadHist, speedHist)
639+
.forEach { it.removeAll { s -> s.timestampMs < cutoff } }
629640
_fullHistory.value = FullMetricHistory(
630641
battery = battHist.toList(),
631642
temperature = tempHist.toList(),

app/src/main/java/com/eried/eucplanet/service/VoiceService.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,12 +395,22 @@ class VoiceService @Inject constructor(
395395
else -> false
396396
}
397397
if (enabled) {
398+
// Convert each value to the user's display unit before
399+
// formatting. The "kilometers / miles" wording in
400+
// voice_trip_fmt also switches via the imperial variant.
401+
val imperial = settings.imperialUnits
402+
val displaySpeed = com.eried.eucplanet.util.Units.speed(data.speed, imperial)
403+
val displayTemp = com.eried.eucplanet.util.Units.temperature(data.maxTemperature, imperial)
404+
val displayTrip = com.eried.eucplanet.util.Units.distance(data.tripDistance, imperial)
398405
when (item) {
399-
"Speed" -> parts.add(context.getString(R.string.voice_speed_fmt, "%.0f".format(data.speed)))
406+
"Speed" -> parts.add(context.getString(R.string.voice_speed_fmt, "%.0f".format(displaySpeed)))
400407
"Battery" -> parts.add(context.getString(R.string.voice_battery_fmt, data.batteryPercent))
401-
"Temp" -> parts.add(context.getString(R.string.voice_temp_fmt, "%.0f".format(data.maxTemperature)))
408+
"Temp" -> parts.add(context.getString(R.string.voice_temp_fmt, "%.0f".format(displayTemp)))
402409
"PWM" -> parts.add(context.getString(R.string.voice_load_fmt, "%.0f".format(data.pwm)))
403-
"Distance" -> parts.add(context.getString(R.string.voice_trip_fmt, "%.1f".format(data.tripDistance)))
410+
"Distance" -> parts.add(context.getString(
411+
if (imperial) R.string.voice_trip_miles_fmt else R.string.voice_trip_fmt,
412+
"%.1f".format(displayTrip)
413+
))
404414
"Recording" -> parts.add(context.getString(if (isRecording) R.string.voice_recording_on else R.string.voice_recording_off))
405415
"Time" -> parts.add(context.getString(R.string.voice_time_fmt,
406416
android.text.format.DateFormat.getTimeFormat(context).format(java.util.Date())))

app/src/main/java/com/eried/eucplanet/service/WheelService.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ class WheelService : LifecycleService() {
4545

4646
@Inject lateinit var wheelRepository: WheelRepository
4747
@Inject lateinit var settingsRepository: SettingsRepository
48+
49+
@Volatile
50+
private var imperialCached: Boolean = false
4851
@Inject lateinit var voiceService: VoiceService
4952
@Inject lateinit var tripRepository: TripRepository
5053
@Inject lateinit var automationManager: AutomationManager
5154
@Inject lateinit var engineSoundEngine: EngineSoundEngine
55+
@Inject lateinit var wearBridge: com.eried.eucplanet.wear.WearBridge
5256

5357
// Voice announcement
5458
private var voiceJob: Job? = null
@@ -105,6 +109,9 @@ class WheelService : LifecycleService() {
105109
// Apply engine settings + lifecycle on settings changes and connection
106110
lifecycleScope.launch {
107111
settingsRepository.settings.collect { s ->
112+
// Notification builder reads imperial without suspending —
113+
// mirror the latest value here every settings update.
114+
imperialCached = s.imperialUnits
108115
engineSoundEngine.applySettings(s)
109116
engineSoundEngine.setConnected(
110117
wheelRepository.connectionState.value == ConnectionState.CONNECTED,
@@ -229,6 +236,11 @@ class WheelService : LifecycleService() {
229236
}
230237

231238
override fun onDestroy() {
239+
// Send one last DataMap so the watch flips to its disconnected
240+
// ("—") state instantly. If the process is hard-killed and this
241+
// line never runs, the watch's 3-s stale timer kicks in as
242+
// fallback. Either way the rider never sees a frozen-stale dial.
243+
try { wearBridge.publishFarewell() } catch (_: Exception) {}
232244
voiceJob?.cancel()
233245
engineSoundEngine.stop()
234246
voiceService.shutdown()
@@ -343,7 +355,9 @@ class WheelService : LifecycleService() {
343355
)
344356

345357
val text = if (data != null && data.speed > 0) {
346-
"%.1f km/h | %d%% | %.1f V".format(data.speed, data.batteryPercent, data.voltage)
358+
val displaySpeed = com.eried.eucplanet.util.Units.speed(data.speed, imperialCached)
359+
val speedUnit = com.eried.eucplanet.util.Units.speedUnit(this, imperialCached)
360+
"%.1f %s | %d%% | %.1f V".format(displaySpeed, speedUnit, data.batteryPercent, data.voltage)
347361
} else {
348362
val state = wheelRepository.connectionState.value
349363
state.name.lowercase().replaceFirstChar { it.uppercase() }

app/src/main/java/com/eried/eucplanet/ui/dashboard/MetricDetailScreen.kt

Lines changed: 151 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
package com.eried.eucplanet.ui.dashboard
22

3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.core.animateFloatAsState
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.animation.fadeIn
7+
import androidx.compose.animation.fadeOut
38
import androidx.compose.foundation.Canvas
49
import androidx.compose.foundation.background
10+
import androidx.compose.foundation.clickable
511
import androidx.compose.foundation.gestures.awaitEachGesture
612
import androidx.compose.foundation.gestures.awaitFirstDown
13+
import androidx.compose.foundation.gestures.detectTapGestures
714
import androidx.compose.foundation.layout.Arrangement
815
import androidx.compose.foundation.layout.Box
916
import androidx.compose.foundation.layout.Column
@@ -13,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize
1320
import androidx.compose.foundation.layout.fillMaxWidth
1421
import androidx.compose.foundation.layout.height
1522
import androidx.compose.foundation.layout.padding
23+
import androidx.compose.foundation.layout.size
1624
import androidx.compose.foundation.rememberScrollState
1725
import androidx.compose.foundation.shape.RoundedCornerShape
1826
import androidx.compose.foundation.verticalScroll
@@ -146,7 +154,9 @@ fun MetricDetailScreen(
146154
Spacer(Modifier.height(8.dp))
147155

148156
if (samples.size >= 2) {
149-
val windowSamples = samples.takeLast(300)
157+
// History is already time-windowed to 5 min in WheelRepository,
158+
// so the slice here is just defensive — no need to cap by count.
159+
val windowSamples = samples
150160
val values = windowSamples.map { it.value }
151161
val min = values.min()
152162
val max = values.max()
@@ -278,6 +288,32 @@ private fun MetricGraph(
278288
var frozenSamples by remember { mutableStateOf<List<MetricSample>?>(null) }
279289
val latestSamples = rememberUpdatedState(samples)
280290

291+
// Zoom state. zoomTarget = 1f means "see the full window"; 2f means
292+
// "see half the time range centered on panFractionTarget". The animated
293+
// values drive rendering so resets snap smoothly back to 1.0 over ~250ms.
294+
var zoomTarget by remember { mutableStateOf(1f) }
295+
var panFractionTarget by remember { mutableStateOf(0.5f) }
296+
val zoom by animateFloatAsState(
297+
targetValue = zoomTarget,
298+
animationSpec = tween(250),
299+
label = "graphZoom"
300+
)
301+
val panFraction by animateFloatAsState(
302+
targetValue = panFractionTarget,
303+
animationSpec = tween(250),
304+
label = "graphPan"
305+
)
306+
val isZoomed = zoomTarget > 1.001f
307+
308+
// While zoomed we keep the snapshot frozen so live samples don't push the
309+
// user's zoomed-in moment off the right edge. Reset both together.
310+
val resetZoom = {
311+
zoomTarget = 1f
312+
panFractionTarget = 0.5f
313+
frozenSamples = null
314+
touchX = null
315+
}
316+
281317
val displaySamples = frozenSamples ?: samples
282318

283319
Box(
@@ -289,32 +325,97 @@ private fun MetricGraph(
289325
modifier = Modifier
290326
.fillMaxSize()
291327
.padding(start = 44.dp, bottom = 28.dp, top = 12.dp, end = 12.dp)
328+
// Double-tap anywhere resets zoom + unfreezes live data. Runs
329+
// on its own pointerInput so it composes cleanly with the
330+
// scrub / pinch handler below — tap detection only fires on
331+
// quick taps so it doesn't shadow press-and-hold scrubbing.
332+
.pointerInput(Unit) {
333+
detectTapGestures(onDoubleTap = { resetZoom() })
334+
}
292335
.pointerInput(Unit) {
293336
awaitEachGesture {
294-
val down = awaitFirstDown(requireUnconsumed = false)
295-
// Snapshot current samples so the graph stops sliding while held
296-
frozenSamples = latestSamples.value
297-
touchX = down.position.x
298-
down.consume()
337+
awaitFirstDown(requireUnconsumed = false)
338+
// Snapshot current samples; if we're not already
339+
// zoomed, this also freezes the live-slide.
340+
if (frozenSamples == null) frozenSamples = latestSamples.value
341+
342+
// Pinch tracking. We capture the time-fraction under
343+
// the pinch midpoint at gesture start so the zoom
344+
// anchors to where the user's fingers are, instead
345+
// of always centering on the middle of the window.
346+
var initialPinchDist: Float? = null
347+
var initialZoom = zoomTarget
348+
var anchorTimeFrac = 0.5f
349+
var pinchFracStart = 0.5f
350+
299351
while (true) {
300352
val ev = awaitPointerEvent()
301-
val change = ev.changes.firstOrNull() ?: break
302-
if (!change.pressed) {
353+
val pressed = ev.changes.filter { it.pressed }
354+
if (pressed.isEmpty()) break
355+
356+
if (pressed.size >= 2) {
357+
// Two-finger pinch. Hide tooltip and start
358+
// (or continue) a zoom session.
303359
touchX = null
304-
frozenSamples = null
305-
break
360+
val p1 = pressed[0].position
361+
val p2 = pressed[1].position
362+
val dist = (p1 - p2).getDistance()
363+
if (initialPinchDist == null) {
364+
initialPinchDist = dist
365+
initialZoom = zoomTarget
366+
pinchFracStart = ((p1.x + p2.x) / 2f / size.width)
367+
.coerceIn(0f, 1f)
368+
val visStart = panFractionTarget - 0.5f / zoomTarget
369+
val visWidth = 1f / zoomTarget
370+
anchorTimeFrac = (visStart + pinchFracStart * visWidth)
371+
.coerceIn(0f, 1f)
372+
} else {
373+
val scale = dist / initialPinchDist!!
374+
val newZoom = (initialZoom * scale).coerceIn(1f, 20f)
375+
zoomTarget = newZoom
376+
val newPan = anchorTimeFrac +
377+
(0.5f - pinchFracStart) / newZoom
378+
val halfVis = 0.5f / newZoom
379+
panFractionTarget = newPan.coerceIn(halfVis, 1f - halfVis)
380+
}
381+
} else {
382+
// Single finger — scrub for tooltip.
383+
initialPinchDist = null
384+
touchX = pressed[0].position.x
306385
}
307-
touchX = change.position.x
308-
change.consume()
386+
ev.changes.forEach { it.consume() }
309387
}
388+
389+
// All fingers released. Drop tooltip; unfreeze the
390+
// sample list only if we're NOT still zoomed in.
391+
touchX = null
392+
if (zoomTarget <= 1.001f) frozenSamples = null
310393
}
311394
}
312395
) {
313396
if (displaySamples.size < 2) return@Canvas
314397
val w = size.width
315398
val h = size.height
316399

317-
val values = displaySamples.map { it.value }
400+
// Apply zoom: pick the slice of displaySamples in the visible
401+
// time window. The window has width 1/zoom and is centered on
402+
// panFraction (both in [0..1] of the full snapshot's time range).
403+
val fullStart = displaySamples.first().timestampMs
404+
val fullEnd = displaySamples.last().timestampMs
405+
val fullSpanMs = (fullEnd - fullStart).coerceAtLeast(1)
406+
val halfVis = 0.5f / zoom
407+
val visStartFrac = (panFraction - halfVis).coerceIn(0f, 1f - 2 * halfVis)
408+
val visEndFrac = (visStartFrac + 2 * halfVis).coerceAtMost(1f)
409+
val visStartMs = fullStart + (visStartFrac * fullSpanMs).toLong()
410+
val visEndMs = fullStart + (visEndFrac * fullSpanMs).toLong()
411+
val visibleSamples = if (zoom <= 1.001f) {
412+
displaySamples
413+
} else {
414+
displaySamples.filter { it.timestampMs in visStartMs..visEndMs }
415+
.ifEmpty { displaySamples }
416+
}
417+
418+
val values = visibleSamples.map { it.value }
318419
val bounds = boundsFor(values.min(), values.max())
319420
val graphMin = bounds.min
320421
val graphRange = bounds.range
@@ -331,9 +432,9 @@ private fun MetricGraph(
331432
drawText(measured, topLeft = Offset(-measured.size.width - 4f, y - measured.size.height / 2f))
332433
}
333434

334-
// Time axis labels
335-
val startTime = displaySamples.first().timestampMs
336-
val endTime = displaySamples.last().timestampMs
435+
// Time axis labels — reflect the currently-visible zoom window.
436+
val startTime = visibleSamples.first().timestampMs
437+
val endTime = visibleSamples.last().timestampMs
337438
val totalSec = ((endTime - startTime) / 1000).toInt().coerceAtLeast(1)
338439
val timeSteps = if (totalSec > 300) 5 else if (totalSec > 60) 4 else 3
339440
for (i in 0..timeSteps) {
@@ -345,10 +446,10 @@ private fun MetricGraph(
345446
drawText(measured, topLeft = Offset(x - measured.size.width / 2f, h + 4f))
346447
}
347448

348-
// Data line
449+
// Data line — only samples in the visible window are drawn.
349450
val timeRange = (endTime - startTime).coerceAtLeast(1)
350451
val path = androidx.compose.ui.graphics.Path()
351-
displaySamples.forEachIndexed { idx, sample ->
452+
visibleSamples.forEachIndexed { idx, sample ->
352453
val x = ((sample.timestampMs - startTime).toFloat() / timeRange) * w
353454
val y = h - ((sample.value - graphMin) / graphRange) * h
354455
if (idx == 0) path.moveTo(x, y) else path.lineTo(x, y)
@@ -371,12 +472,12 @@ private fun MetricGraph(
371472

372473
// Find bracketing samples for interpolation
373474
var leftIdx = 0
374-
for (i in displaySamples.indices) {
375-
if (displaySamples[i].timestampMs <= targetMs) leftIdx = i else break
475+
for (i in visibleSamples.indices) {
476+
if (visibleSamples[i].timestampMs <= targetMs) leftIdx = i else break
376477
}
377-
val rightIdx = (leftIdx + 1).coerceAtMost(displaySamples.size - 1)
378-
val left = displaySamples[leftIdx]
379-
val right = displaySamples[rightIdx]
478+
val rightIdx = (leftIdx + 1).coerceAtMost(visibleSamples.size - 1)
479+
val left = visibleSamples[leftIdx]
480+
val right = visibleSamples[rightIdx]
380481
val span = (right.timestampMs - left.timestampMs).coerceAtLeast(1)
381482
val frac = ((targetMs - left.timestampMs).toFloat() / span).coerceIn(0f, 1f)
382483
val interpValue = left.value + (right.value - left.value) * frac
@@ -405,6 +506,33 @@ private fun MetricGraph(
405506
drawText(measured, topLeft = Offset(boxX + padX, boxY + padY))
406507
}
407508
}
509+
510+
// ▶ Live chip — only shown while a zoom is active. Tap to snap back
511+
// and resume live sliding. Doubles as the "you are paused" indicator
512+
// so the user has an obvious way out of the inspection mode.
513+
AnimatedVisibility(
514+
visible = isZoomed,
515+
enter = fadeIn(tween(150)),
516+
exit = fadeOut(tween(150)),
517+
modifier = Modifier
518+
.align(Alignment.TopEnd)
519+
.padding(top = 8.dp, end = 8.dp)
520+
) {
521+
Box(
522+
modifier = Modifier
523+
.clip(RoundedCornerShape(14.dp))
524+
.background(color.copy(alpha = 0.85f))
525+
.clickable { resetZoom() }
526+
.padding(horizontal = 10.dp, vertical = 4.dp)
527+
) {
528+
Text(
529+
text = "Reset",
530+
fontSize = 11.sp,
531+
fontWeight = FontWeight.Medium,
532+
color = Color.Black
533+
)
534+
}
535+
}
408536
}
409537
}
410538

0 commit comments

Comments
 (0)