Skip to content

Commit 8f16952

Browse files
eriedclaude
andcommitted
Watch: dashboard always renders, disconnected = zeros + grayed buttons
When the wheel isn't connected the watch used to swap to a placeholder screen ("Open EUC Planet on your phone"). Tester feedback: the layout should stay put with every metric at 0 and the action buttons grayed out, mirroring how the phone dashboard looks when disconnected. The header on the details page replaces the wheel-name accent with a muted "Wheel disconnected" so the rider still knows the values aren't live. A side benefit: phone-side settings tweaks (PWM display, batteries, speed unit, light icon, etc.) now show up on the watch immediately, even without a wheel paired — testers can validate the layout end to end before they ever turn the wheel on. Bridge fix: the publish pipeline used a Flow combine + sample + distinctUntilChanged combo that went silent when nothing upstream changed (e.g. wheel disconnected, no telemetry tick). Replaced with a plain coroutine loop ticking at PUBLISH_INTERVAL_MS so the watch's freshness signal stays alive regardless of whether the wheel is producing data. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2975c8e commit 8f16952

4 files changed

Lines changed: 74 additions & 71 deletions

File tree

app/src/main/java/com/eried/eucplanet/wear/WearBridge.kt

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
1717
import kotlinx.coroutines.CoroutineScope
1818
import kotlinx.coroutines.Dispatchers
1919
import kotlinx.coroutines.SupervisorJob
20-
import kotlinx.coroutines.flow.combine
21-
import kotlinx.coroutines.flow.distinctUntilChanged
22-
import kotlinx.coroutines.flow.sample
20+
import kotlinx.coroutines.delay
2321
import kotlinx.coroutines.launch
2422
import javax.inject.Inject
2523
import javax.inject.Singleton
@@ -113,21 +111,27 @@ class WearBridge @Inject constructor(
113111
}
114112
}
115113

114+
// Periodic publisher rather than a Flow combine. Reasoning: when the
115+
// wheel is disconnected the upstream flows don't emit, so a sample +
116+
// distinctUntilChanged pipeline goes silent — and the watch ends up
117+
// in the "phone not here" placeholder even though the phone app is
118+
// running and paired. Polling at PUBLISH_INTERVAL_MS keeps the
119+
// watch's freshness signal alive without per-emission complexity.
116120
scope.launch {
117-
combine(
118-
wheelRepository.wheelData,
119-
wheelRepository.connectionState,
120-
wheelRepository.modelName,
121-
wheelRepository.maxSpeedCap,
122-
settingsRepository.settings
123-
) { data, state, name, maxSpeed, settings ->
124-
Snapshot(data, state, name, maxSpeed, settings)
125-
}
126-
.sample(PUBLISH_INTERVAL_MS)
127-
.distinctUntilChanged()
128-
.collect { snap ->
129-
publish(snap.data, snap.state, snap.name, snap.maxSpeed, snap.settings)
121+
while (true) {
122+
try {
123+
publish(
124+
data = wheelRepository.wheelData.value,
125+
state = wheelRepository.connectionState.value,
126+
name = wheelRepository.modelName.value,
127+
maxSpeed = wheelRepository.maxSpeedCap.value,
128+
settings = settingsRepository.get()
129+
)
130+
} catch (e: Exception) {
131+
Log.w(TAG, "publish loop error", e)
130132
}
133+
delay(PUBLISH_INTERVAL_MS)
134+
}
131135
}
132136
}
133137

@@ -190,11 +194,4 @@ class WearBridge @Inject constructor(
190194
return if (level >= 0 && scale > 0) (level * 100 / scale).coerceIn(0, 100) else 0
191195
}
192196

193-
private data class Snapshot(
194-
val data: com.eried.eucplanet.data.model.WheelData,
195-
val state: ConnectionState,
196-
val name: String?,
197-
val maxSpeed: Float,
198-
val settings: AppSettings
199-
)
200197
}

wear/src/main/java/com/eried/eucplanet/wear/bridge/WatchStateRepository.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,19 @@ object WatchStateRepository {
2828
private val _state = MutableStateFlow(WatchState())
2929
val state: StateFlow<WatchState> = _state.asStateFlow()
3030

31+
/**
32+
* Wall-clock time of the last [update] call, in millis. Used by the UI to
33+
* tell apart the "phone hasn't said hi yet" placeholder from the
34+
* "phone is here, just no wheel" one. Zero until the first push lands.
35+
*/
36+
private val _lastPushAtMs = MutableStateFlow(0L)
37+
val lastPushAtMs: StateFlow<Long> = _lastPushAtMs.asStateFlow()
38+
3139
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
3240

3341
fun update(snapshot: WatchState) {
3442
_state.value = snapshot
43+
_lastPushAtMs.value = System.currentTimeMillis()
3544
}
3645

3746
/**

wear/src/main/java/com/eried/eucplanet/wear/ui/WatchApp.kt

Lines changed: 44 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ private fun MainScreen(state: WatchState, accent: Color) {
8787
.background(Color.Black),
8888
contentAlignment = Alignment.Center
8989
) {
90-
if (!state.connected) {
91-
DisconnectedPlaceholder()
92-
return@BoxWithConstraints
93-
}
90+
// The disconnected state is rendered by zeroing every metric and
91+
// graying the action buttons rather than swapping in a placeholder
92+
// screen — the rider always sees the same layout, and the second
93+
// page (DetailsScreen) is where the explicit "Disconnected" hint
94+
// lives. Mirrors the phone dashboard's behaviour.
9495
val sw = maxWidth.value
9596
val batteryFontSp = (sw * 0.034f).coerceIn(9f, 12f).sp
9697
val batteryIconDp = (sw * 0.038f).coerceIn(10f, 14f).dp
@@ -253,39 +254,6 @@ private fun LoadBar(percent: Float, modifier: Modifier = Modifier) {
253254
}
254255
}
255256

256-
@Composable
257-
private fun DisconnectedPlaceholder() {
258-
BoxWithConstraints(
259-
modifier = Modifier
260-
.fillMaxSize()
261-
.padding(horizontal = 16.dp),
262-
contentAlignment = Alignment.Center
263-
) {
264-
val sw = maxWidth.value
265-
val iconDp = (sw * 0.18f).coerceIn(36f, 56f).dp
266-
val textSp = (sw * 0.038f).coerceIn(12f, 15f).sp
267-
Column(
268-
horizontalAlignment = Alignment.CenterHorizontally,
269-
verticalArrangement = Arrangement.Center
270-
) {
271-
Icon(
272-
imageVector = Icons.Filled.PhoneAndroid,
273-
contentDescription = null,
274-
tint = Color(0xFF9AA0A6),
275-
modifier = Modifier.size(iconDp)
276-
)
277-
Spacer(Modifier.height(8.dp))
278-
Text(
279-
text = stringResource(R.string.watch_waiting_phone),
280-
fontSize = textSp,
281-
color = Color(0xFFB0B0B0),
282-
textAlign = TextAlign.Center,
283-
fontWeight = FontWeight.Medium
284-
)
285-
}
286-
}
287-
}
288-
289257
@Composable
290258
private fun BatteryRow(
291259
wheelPercent: Int?,
@@ -342,13 +310,21 @@ private fun batteryTint(percent: Int): Color = when {
342310
@Composable
343311
private fun ActionRow(state: WatchState, accent: Color, buttonSize: Dp, iconSize: Dp) {
344312
val context = LocalContext.current
313+
val enabled = state.connected
314+
// Use the same dim grey as the phone dashboard's grayed action tiles so
315+
// disconnected reads identically across surfaces.
316+
val disabledBg = Color(0xFF1A1A1A)
317+
val disabledFg = Color(0xFF555555)
345318
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
346319
if (state.hasHorn) {
347320
Button(
348-
onClick = { WatchStateRepository.sendControl(context, WatchControl.HORN) },
321+
onClick = {
322+
if (enabled) WatchStateRepository.sendControl(context, WatchControl.HORN)
323+
},
324+
enabled = enabled,
349325
colors = ButtonDefaults.primaryButtonColors(
350-
backgroundColor = accent,
351-
contentColor = Color.Black
326+
backgroundColor = if (enabled) accent else disabledBg,
327+
contentColor = if (enabled) Color.Black else disabledFg
352328
),
353329
modifier = Modifier.size(buttonSize)
354330
) {
@@ -362,19 +338,33 @@ private fun ActionRow(state: WatchState, accent: Color, buttonSize: Dp, iconSize
362338
if (state.hasLight) {
363339
Button(
364340
onClick = {
341+
if (!enabled) return@Button
365342
val intent = if (state.lightOn) WatchControl.LIGHT_OFF else WatchControl.LIGHT_ON
366343
WatchStateRepository.sendControl(context, intent)
367344
},
345+
enabled = enabled,
368346
colors = ButtonDefaults.secondaryButtonColors(
369-
backgroundColor = if (state.lightOn) accent.copy(alpha = 0.30f) else Color(0xFF2A2A2A),
370-
contentColor = if (state.lightOn) accent else Color(0xFFB0B0B0)
347+
backgroundColor = when {
348+
!enabled -> disabledBg
349+
state.lightOn -> accent.copy(alpha = 0.30f)
350+
else -> Color(0xFF2A2A2A)
351+
},
352+
contentColor = when {
353+
!enabled -> disabledFg
354+
state.lightOn -> accent
355+
else -> Color(0xFFB0B0B0)
356+
}
371357
),
372358
modifier = Modifier.size(buttonSize)
373359
) {
374360
Icon(
375361
imageVector = Icons.Filled.FlashlightOn,
376362
contentDescription = stringResource(R.string.watch_light),
377-
tint = if (state.lightOn) Color(0xFFFFC107) else Color(0xFF606060),
363+
tint = when {
364+
!enabled -> disabledFg
365+
state.lightOn -> Color(0xFFFFC107)
366+
else -> Color(0xFF606060)
367+
},
378368
modifier = Modifier.size(iconSize)
379369
)
380370
}
@@ -390,10 +380,6 @@ private fun DetailsScreen(state: WatchState, accent: Color) {
390380
.background(Color.Black),
391381
contentAlignment = Alignment.Center
392382
) {
393-
if (!state.connected) {
394-
DisconnectedPlaceholder()
395-
return@BoxWithConstraints
396-
}
397383
val sw = maxWidth.value
398384
val labelSp = (sw * 0.034f).coerceIn(10f, 13f).sp
399385
val valueSp = (sw * 0.038f).coerceIn(11f, 14f).sp
@@ -418,7 +404,17 @@ private fun DetailsScreen(state: WatchState, accent: Color) {
418404
horizontalAlignment = Alignment.CenterHorizontally,
419405
verticalArrangement = Arrangement.spacedBy(2.dp)
420406
) {
421-
if (state.wheelName.isNotBlank()) {
407+
// When disconnected we still render every metric (all zeros) but
408+
// swap the wheel-name accent header for a muted "Disconnected"
409+
// hint so the rider knows the values aren't live.
410+
if (!state.connected) {
411+
Text(
412+
text = stringResource(R.string.watch_disconnected),
413+
fontSize = headerSp,
414+
color = Color(0xFF9AA0A6),
415+
fontWeight = FontWeight.SemiBold
416+
)
417+
} else if (state.wheelName.isNotBlank()) {
422418
Text(
423419
text = state.wheelName,
424420
fontSize = headerSp,

wear/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<resources>
33
<string name="app_name">EUC Planet</string>
44
<string name="watch_waiting_phone">Open EUC Planet\non your phone</string>
5+
<string name="watch_waiting_wheel">Connect a wheel\nin EUC Planet</string>
56
<string name="watch_disconnected">Wheel disconnected</string>
67
<string name="watch_horn">Horn</string>
78
<string name="watch_light">Light</string>

0 commit comments

Comments
 (0)