Skip to content

Commit 611ab3e

Browse files
eriedclaude
andcommitted
Add Varia rear-view radar overlay widget
New OverlayElementType.RADAR Overlay Studio control with three view modes (Lane / Mirror / Minimal), rendered both in the phone Overlay Studio and, via the existing customOverlayJson pipe, on the HUD's Custom screen. The radar data already exists on the phone (RadarRepository); this surfaces it for video overlays and the helmet HUD. hud-protocol: - HudState gains radarConnected / radarBatteryPercent / radarTargets (List<RadarTargetWire>); PROTOCOL_MINOR 7 -> 8 (additive, old HUDs ignore). - OverlayElementType.RADAR + radarMode / radarRangeM / radarShowDistanceLabels on OverlayElement. app: - HudServer.snapshot() maps the live RadarRepository frame to the wire. - HudDemoSource synthesises closing cars so the widget is testable on an emulator without a Varia. - RadarElement renderer (3 modes; threat colours from theme status tokens) + Studio editor wiring (picker entry, icon, config sheet, defaults) + OverlayPresetJson radar fields (save/load + wire payload). hud: - RadarElement renderer twin; StudioElementData carries the radar frame; CustomOverlayScreen's hand-rolled parser reads the radar fields. tests: - Wire round-trip + additive-compat (frozen v1.0 still decodes, MINOR bump). The Varia is rear-facing with no bearing, so Mirror lights both sides by the worst threat level (spelled out in the config hint). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9d90e9 commit 611ab3e

14 files changed

Lines changed: 920 additions & 12 deletions

File tree

app/src/main/java/com/eried/eucplanet/data/store/OverlayPresetJson.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ object OverlayPresetJson {
162162
put("dialShowColorBand", el.dialShowColorBand)
163163
put("dialOrangeThresholdPct", el.dialOrangeThresholdPct)
164164
put("dialRedThresholdPct", el.dialRedThresholdPct)
165+
put("radarMode", el.radarMode)
166+
put("radarRangeM", el.radarRangeM.toDouble())
167+
put("radarShowDistanceLabels", el.radarShowDistanceLabels)
165168
}
166169

167170
private fun elementFromJson(o: JSONObject): OverlayElement? {
@@ -225,7 +228,12 @@ object OverlayPresetJson {
225228
unitPosition = o.optString("unitPosition", d.unitPosition),
226229
dialShowColorBand = o.optBoolean("dialShowColorBand", d.dialShowColorBand),
227230
dialOrangeThresholdPct = o.optInt("dialOrangeThresholdPct", d.dialOrangeThresholdPct),
228-
dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct)
231+
dialRedThresholdPct = o.optInt("dialRedThresholdPct", d.dialRedThresholdPct),
232+
radarMode = o.optString("radarMode", d.radarMode),
233+
radarRangeM = o.optDouble("radarRangeM", d.radarRangeM.toDouble()).toFloat(),
234+
radarShowDistanceLabels = o.optBoolean(
235+
"radarShowDistanceLabels", d.radarShowDistanceLabels
236+
)
229237
)
230238
}
231239

app/src/main/java/com/eried/eucplanet/service/hud/HudDemoSource.kt

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.eried.eucplanet.service.hud
22

33
import android.util.Log
44
import com.eried.eucplanet.hud.protocol.HudState
5+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.Job
@@ -115,6 +116,11 @@ class HudDemoSource {
115116
}
116117
val arrived = phase > 0.97
117118

119+
// Rear-view radar: a main car closing 140 -> 8 m across the loop, plus
120+
// a couple of extras phased in so the widget shows several colour
121+
// bands (green far, amber mid, red near) and multiple blips at once.
122+
val radarTargets = radarTargets(phase)
123+
118124
return Frame(
119125
speedKmh = speedKmh,
120126
batteryPct = battery,
@@ -131,10 +137,42 @@ class HudDemoSource {
131137
navAngleDeg = turnAngle,
132138
navPrimary = navPrimary,
133139
navDistance = if (arrived) "" else "${distM} m",
134-
navArrived = arrived
140+
navArrived = arrived,
141+
radarConnected = true,
142+
radarBatteryPercent = 78,
143+
radarTargets = radarTargets
135144
)
136145
}
137146

147+
/** Scripted radar cars for the demo. Distances chosen so a full loop
148+
* walks the LANE/MIRROR colour bands; ids stay stable while a car is
149+
* present so the renderer can track it. */
150+
private fun radarTargets(phase: Double): List<RadarTargetWire> {
151+
val targets = mutableListOf<RadarTargetWire>()
152+
// Main car: closes steadily over the whole cycle.
153+
val mainDist = (140.0 - 132.0 * phase).toInt().coerceIn(6, 140)
154+
targets += RadarTargetWire(1, mainDist, 22, radarLevel(mainDist))
155+
// Second car: drifts in for the back two-thirds of the loop.
156+
if (phase > 0.35) {
157+
val d2 = (95.0 - 60.0 * ((phase - 0.35) / 0.65)).toInt().coerceIn(20, 95)
158+
targets += RadarTargetWire(2, d2, 9, radarLevel(d2))
159+
}
160+
// Third car: a brief fast approacher mid-cycle (always red).
161+
if (phase in 0.45..0.75) {
162+
val d3 = (40.0 - 30.0 * ((phase - 0.45) / 0.30)).toInt().coerceIn(8, 40)
163+
targets += RadarTargetWire(3, d3, 46, 2)
164+
}
165+
return targets
166+
}
167+
168+
/** Demo threat level by distance: red <=30 m, amber <=80 m, else green.
169+
* Mirrors the visual bands the LANE/MIRROR renderers use. */
170+
private fun radarLevel(distM: Int): Int = when {
171+
distM <= 30 -> 2
172+
distM <= 80 -> 1
173+
else -> 0
174+
}
175+
138176
data class Frame(
139177
val speedKmh: Float = 0f,
140178
val batteryPct: Int = 100,
@@ -150,6 +188,9 @@ class HudDemoSource {
150188
val navAngleDeg: Float = 0f,
151189
val navPrimary: String = "",
152190
val navDistance: String = "",
153-
val navArrived: Boolean = false
191+
val navArrived: Boolean = false,
192+
val radarConnected: Boolean = false,
193+
val radarBatteryPercent: Int = -1,
194+
val radarTargets: List<RadarTargetWire> = emptyList()
154195
)
155196
}

app/src/main/java/com/eried/eucplanet/service/hud/HudServer.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import com.eried.eucplanet.ble.ConnectionState
77
import com.eried.eucplanet.data.model.AppSettings
88
import com.eried.eucplanet.data.model.arrowAngleDeg
99
import com.eried.eucplanet.data.repository.ExternalGpsRepository
10+
import com.eried.eucplanet.data.repository.RadarRepository
1011
import com.eried.eucplanet.data.repository.SettingsRepository
1112
import com.eried.eucplanet.data.repository.TripRepository
1213
import com.eried.eucplanet.data.repository.WheelRepository
1314
import com.eried.eucplanet.hud.protocol.HudCommand
1415
import com.eried.eucplanet.hud.protocol.HudDebug
1516
import com.eried.eucplanet.hud.protocol.HudDiscovery
1617
import com.eried.eucplanet.hud.protocol.HudState
18+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
1719
import com.eried.eucplanet.nav.NavigationEngine
1820
import com.eried.eucplanet.ui.theme.AccentOptions
1921
import com.eried.eucplanet.ui.theme.AccentTeal
@@ -67,6 +69,7 @@ class HudServer @Inject constructor(
6769
private val settingsRepository: SettingsRepository,
6870
private val externalGpsRepository: ExternalGpsRepository,
6971
private val tripRepository: TripRepository,
72+
private val radarRepository: RadarRepository,
7073
private val navigationEngine: NavigationEngine,
7174
private val commandSink: HudCommandSink,
7275
private val themeController: com.eried.eucplanet.ui.theme.ThemeController
@@ -405,6 +408,12 @@ class HudServer @Inject constructor(
405408

406409
val d = if (demo.active) demo.frame else null
407410

411+
// Rear-view radar (Varia). In demo mode the synthetic source supplies
412+
// scripted cars; otherwise read the live frame. "connected" gates the
413+
// HUD radar widget between "idle / no radar" and "lane clear".
414+
val radarFrame = radarRepository.currentFrame.value
415+
val radarLive = radarRepository.connectionState.value == ConnectionState.CONNECTED
416+
408417
// Debug-only protocol-version overrides so a tester can drive
409418
// the version-mismatch UI on a single APK pair without rebuilding.
410419
// Set via:
@@ -496,6 +505,18 @@ class HudServer @Inject constructor(
496505
joystickDown = joystickLabel(s.hudActionDown),
497506
joystickLeft = joystickLabel(s.hudActionLeft),
498507
joystickRight = joystickLabel(s.hudActionRight),
508+
radarConnected = if (d != null) d.radarConnected else radarLive,
509+
radarBatteryPercent = if (d != null) d.radarBatteryPercent
510+
else (radarFrame?.batteryPercent ?: -1),
511+
radarTargets = if (d != null) d.radarTargets
512+
else radarFrame?.threats?.take(8)?.map {
513+
RadarTargetWire(
514+
id = it.id,
515+
distanceM = it.distanceM,
516+
approachSpeedKmh = it.approachSpeedKmh,
517+
level = it.threatLevel.ordinal
518+
)
519+
}.orEmpty(),
499520
timestampMs = System.currentTimeMillis()
500521
)
501522
}

app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioScreen.kt

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ fun OverlayStudioScreen(
141141
val trips by viewModel.trips.collectAsState()
142142
val wheelName by viewModel.wheelName.collectAsState()
143143
val connected by viewModel.connected.collectAsState()
144+
val radar by viewModel.radar.collectAsState()
144145
val history by viewModel.history.collectAsState()
145146
val folderAvailable by viewModel.folderAvailable.collectAsState()
146147
val savedPresets by viewModel.savedPresets.collectAsState()
@@ -938,7 +939,12 @@ fun OverlayStudioScreen(
938939
// this empty; the overlay falls back to wheelData /
939940
// history derived from the scrubbed trip row.
940941
liveGForceTrail = if (replayMode) emptyList() else liveGForceTrail,
941-
riderMarkerPhotoDataUrl = riderMarkerPhoto
942+
riderMarkerPhotoDataUrl = riderMarkerPhoto,
943+
// Radar has no trip-replay history (CSV doesn't store it),
944+
// so it only feeds the live preview; replay shows idle.
945+
radarConnected = if (replayMode) false else radar.connected,
946+
radarBatteryPercent = if (replayMode) -1 else radar.batteryPercent,
947+
radarTargets = if (replayMode) emptyList() else radar.targets
942948
),
943949
editable = editable,
944950
selectedId = selectedId,
@@ -1557,6 +1563,22 @@ private fun newElement(
15571563
rotationDeg = ((360 - deviceRotation) % 360).toFloat()
15581564
)
15591565
}
1566+
if (type == OverlayElementType.RADAR) {
1567+
// Narrow by default: the LANE mode is a tall proximity bar. The rider
1568+
// widens it or switches to MIRROR/MINIMAL from the config sheet.
1569+
return OverlayElement(
1570+
type = type,
1571+
x = nx,
1572+
y = ny,
1573+
width = 0.2f,
1574+
radarMode = "LANE",
1575+
radarRangeM = 140f,
1576+
radarShowDistanceLabels = true,
1577+
foreground = 0xFFFFFFFFL, // lane lines / labels / rider marker
1578+
background = 0x66000000L,
1579+
rotationDeg = ((360 - deviceRotation) % 360).toFloat()
1580+
)
1581+
}
15601582
return OverlayElement(
15611583
type = type,
15621584
x = nx,

app/src/main/java/com/eried/eucplanet/ui/studio/OverlayStudioViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import androidx.lifecycle.viewModelScope
55
import com.eried.eucplanet.ble.ConnectionState
66
import com.eried.eucplanet.hud.protocol.OverlayElement
77
import com.eried.eucplanet.hud.protocol.OverlayPreset
8+
import com.eried.eucplanet.hud.protocol.RadarTargetWire
89
import com.eried.eucplanet.hud.protocol.ViewportConfig
910
import com.eried.eucplanet.hud.protocol.ViewportLayout
1011
import com.eried.eucplanet.data.model.TripRecord
1112
import com.eried.eucplanet.data.model.WheelData
1213
import com.eried.eucplanet.data.repository.ExternalGpsRepository
1314
import com.eried.eucplanet.data.repository.PhoneSensorRepository
15+
import com.eried.eucplanet.data.repository.RadarRepository
1416
import com.eried.eucplanet.data.repository.SettingsRepository
1517
import com.eried.eucplanet.data.repository.TripRepository
1618
import com.eried.eucplanet.data.repository.WheelRepository
@@ -86,6 +88,13 @@ data class ReplayExportPrefs(
8688
/** Outcome of a "save preset" attempt, surfaced to the UI as a snackbar. */
8789
enum class PresetSaveResult { SAVED, NO_FOLDER, FAILED }
8890

91+
/** Snapshot of rear-view radar state handed to the Studio's RADAR element. */
92+
data class StudioRadarState(
93+
val connected: Boolean = false,
94+
val batteryPercent: Int = -1,
95+
val targets: List<RadarTargetWire> = emptyList()
96+
)
97+
8998
/**
9099
* Holds the working Overlay Studio layout and feeds the studio screen its live
91100
* telemetry. The layout is a single [OverlayPreset] mutated through the `update`
@@ -100,6 +109,7 @@ class OverlayStudioViewModel @Inject constructor(
100109
private val tripRepository: TripRepository,
101110
private val phoneSensorRepository: PhoneSensorRepository,
102111
private val externalGpsRepository: ExternalGpsRepository,
112+
private val radarRepository: RadarRepository,
103113
private val navMarkerStore: com.eried.eucplanet.data.store.NavMarkerStore
104114
) : ViewModel() {
105115

@@ -180,6 +190,31 @@ class OverlayStudioViewModel @Inject constructor(
180190
.map { it == ConnectionState.CONNECTED }
181191
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
182192

193+
/**
194+
* Live rear-view radar state for the RADAR overlay element's Studio
195+
* preview. Targets are mapped to the same compact [RadarTargetWire] the
196+
* HUD receives so the phone preview and the mirrored HUD render share one
197+
* renderer shape. Empty / not-connected when no radar is paired; the
198+
* widget then shows its idle "No radar" state.
199+
*/
200+
val radar: StateFlow<StudioRadarState> = combine(
201+
radarRepository.currentFrame,
202+
radarRepository.connectionState
203+
) { frame, conn ->
204+
StudioRadarState(
205+
connected = conn == ConnectionState.CONNECTED,
206+
batteryPercent = frame?.batteryPercent ?: -1,
207+
targets = frame?.threats?.take(8)?.map {
208+
RadarTargetWire(
209+
id = it.id,
210+
distanceM = it.distanceM,
211+
approachSpeedKmh = it.approachSpeedKmh,
212+
level = it.threatLevel.ordinal
213+
)
214+
} ?: emptyList()
215+
)
216+
}.stateIn(viewModelScope, SharingStarted.Eagerly, StudioRadarState())
217+
183218
/**
184219
* Base64 `data:image/png` URL of the rider's custom marker photo (set in
185220
* the Navigator) or null when none has been picked. Exposed here so the

app/src/main/java/com/eried/eucplanet/ui/studio/StudioConfigSheets.kt

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.AddPhotoAlternate
3434
import androidx.compose.material.icons.filled.Badge
3535
import androidx.compose.material.icons.filled.BarChart
3636
import androidx.compose.material.icons.filled.Colorize
37+
import androidx.compose.material.icons.filled.Radar
3738
import androidx.compose.material.icons.filled.TrackChanges
3839
import androidx.compose.material.icons.filled.Dashboard
3940
import androidx.compose.material.icons.filled.Delete
@@ -214,6 +215,7 @@ private val OverlayElementType.labelRes: Int
214215
OverlayElementType.CLOCK -> R.string.studio_element_clock
215216
OverlayElementType.G_FORCE -> R.string.studio_element_g_force
216217
OverlayElementType.MAP -> R.string.studio_element_map
218+
OverlayElementType.RADAR -> R.string.studio_element_radar
217219
}
218220

219221
@Composable
@@ -233,6 +235,7 @@ private val OverlayElementType.icon
233235
OverlayElementType.CLOCK -> Icons.Default.Schedule
234236
OverlayElementType.G_FORCE -> Icons.Default.TrackChanges
235237
OverlayElementType.MAP -> Icons.Default.Map
238+
OverlayElementType.RADAR -> Icons.Default.Radar
236239
}
237240

238241
// --------------------------------------------------------------------------
@@ -582,7 +585,8 @@ private val ADD_ELEMENT_GROUPS: List<Pair<Int, List<OverlayElementType>>> = list
582585
OverlayElementType.DATA_DIAL, // Dial gauge
583586
OverlayElementType.G_FORCE, // G-Force
584587
OverlayElementType.DATA_BAR, // Linear bar
585-
OverlayElementType.MAP // Map
588+
OverlayElementType.MAP, // Map
589+
OverlayElementType.RADAR // Rear-view radar
586590
),
587591
R.string.studio_group_text to listOf(
588592
OverlayElementType.APP_BADGE, // App badge
@@ -663,6 +667,7 @@ private fun elementHint(type: OverlayElementType): String = when (type) {
663667
OverlayElementType.CLOCK -> stringResource(R.string.studio_hint_clock)
664668
OverlayElementType.G_FORCE -> stringResource(R.string.studio_hint_g_force)
665669
OverlayElementType.MAP -> stringResource(R.string.studio_hint_map)
670+
OverlayElementType.RADAR -> stringResource(R.string.studio_hint_radar)
666671
}
667672

668673
// --------------------------------------------------------------------------
@@ -1855,6 +1860,53 @@ fun ElementConfigSheet(
18551860
Spacer(Modifier.height(8.dp))
18561861
}
18571862

1863+
if (element.type == OverlayElementType.RADAR) {
1864+
Text(stringResource(R.string.studio_cfg_radar_mode), fontWeight = FontWeight.SemiBold)
1865+
val radarLane = stringResource(R.string.studio_cfg_radar_lane)
1866+
val radarMirror = stringResource(R.string.studio_cfg_radar_mirror)
1867+
val radarMinimal = stringResource(R.string.studio_cfg_radar_minimal)
1868+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
1869+
listOf(
1870+
"LANE" to radarLane, "MIRROR" to radarMirror,
1871+
"MINIMAL" to radarMinimal
1872+
).forEach { (key, lbl) ->
1873+
FilterChip(
1874+
selected = element.radarMode == key,
1875+
onClick = { onChange(element.copy(radarMode = key)) },
1876+
label = { Text(lbl) },
1877+
colors = themedFilterChipColors(),
1878+
)
1879+
}
1880+
}
1881+
// The Varia is rear-facing only (no left/right bearing), so the
1882+
// Mirror view lights both sides together. Spell that out so the
1883+
// rider isn't surprised it can't point at a specific lane.
1884+
if (element.radarMode == "MIRROR") {
1885+
Text(
1886+
stringResource(R.string.studio_cfg_radar_mirror_hint),
1887+
style = MaterialTheme.typography.bodySmall,
1888+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1889+
modifier = Modifier.padding(top = 4.dp, bottom = 4.dp)
1890+
)
1891+
}
1892+
LabeledSlider(
1893+
stringResource(R.string.studio_cfg_radar_range),
1894+
stringResource(R.string.studio_cfg_radar_range_fmt, element.radarRangeM.roundToInt()),
1895+
element.radarRangeM, 40f, 200f, steps = 7
1896+
) { onChange(element.copy(radarRangeM = it)) }
1897+
if (element.radarMode == "LANE") {
1898+
ToggleRow(
1899+
stringResource(R.string.studio_cfg_radar_distance_labels),
1900+
element.radarShowDistanceLabels
1901+
) { onChange(element.copy(radarShowDistanceLabels = it)) }
1902+
ToggleRow(
1903+
stringResource(R.string.studio_cfg_show_label),
1904+
element.showLabel
1905+
) { onChange(element.copy(showLabel = it)) }
1906+
}
1907+
Spacer(Modifier.height(8.dp))
1908+
}
1909+
18581910
if (element.type == OverlayElementType.FLOATING_CAMERA) {
18591911
Text(stringResource(R.string.studio_cfg_camera_label), fontWeight = FontWeight.SemiBold)
18601912
CameraPicker(cameras, element.cameraKey, inUseKeys) {

0 commit comments

Comments
 (0)