Skip to content

Commit 3495f34

Browse files
eriedclaude
andcommitted
Watch: stem-button mapping, per-metric colors on default accent, dash phone
Watch hardware buttons (Galaxy Watch Ultra: Action button = STEM_1, lower side button = STEM_2) now bind to the FlicAction enum, with separate Click and Long-press slots for each — same dropdown picker the phone uses for Flic and Volume keys. New AppSettings columns (watchStem1Click/Hold, watchStem2Click/Hold) ship over /euc/state. Watch MainActivity intercepts onKeyDown / onKeyLongPress / onKeyUp and either fires the existing dedicated control intents (HORN / LIGHT_*) or sends a generic action: passthrough that PhoneWearListenerService forwards to FlicManager.dispatchActionByName. DB version 28 -> 29. Per-metric coloring on the second page now matches the phone dashboard's default-accent palette: voltage / current / power / trip / torque tinted with the cyan accent, PWM and temperature using the green / amber / red tier scale, speed using the same speed-tier scale as the dial. Custom accent collapses everything to the picked accent so the watch wears a single identity colour. Phone battery chip now shows an em-dash until the phone has pushed at least one snapshot, instead of '0' — '0' read as 'phone is dead' when really the watch just hadn't heard from the phone yet. The chip stays in the row so its layout doesn't reflow when the phone reconnects. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4fbef7 commit 3495f34

9 files changed

Lines changed: 224 additions & 27 deletions

File tree

app/src/main/java/com/eried/eucplanet/ui/settings/SettingsScreen.kt

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,9 +1124,57 @@ private fun WatchTab(
11241124
checked = settings.watchShowSpeedUnit,
11251125
onCheckedChange = { viewModel.updateWatchShowSpeedUnit(it) }
11261126
)
1127+
1128+
SectionHeader(stringResource(R.string.section_watch_buttons))
1129+
Text(
1130+
stringResource(R.string.watch_buttons_help),
1131+
style = MaterialTheme.typography.bodySmall,
1132+
color = MaterialTheme.colorScheme.onSurfaceVariant
1133+
)
1134+
1135+
Text(stringResource(R.string.watch_button_1), style = MaterialTheme.typography.bodyLarge)
1136+
WatchActionPicker(
1137+
label = stringResource(R.string.watch_button_click_label),
1138+
currentKey = settings.watchStem1Click,
1139+
onSelect = { viewModel.updateWatchStem1Click(it) }
1140+
)
1141+
WatchActionPicker(
1142+
label = stringResource(R.string.watch_button_hold_label),
1143+
currentKey = settings.watchStem1Hold,
1144+
onSelect = { viewModel.updateWatchStem1Hold(it) }
1145+
)
1146+
1147+
Text(stringResource(R.string.watch_button_2), style = MaterialTheme.typography.bodyLarge)
1148+
WatchActionPicker(
1149+
label = stringResource(R.string.watch_button_click_label),
1150+
currentKey = settings.watchStem2Click,
1151+
onSelect = { viewModel.updateWatchStem2Click(it) }
1152+
)
1153+
WatchActionPicker(
1154+
label = stringResource(R.string.watch_button_hold_label),
1155+
currentKey = settings.watchStem2Hold,
1156+
onSelect = { viewModel.updateWatchStem2Hold(it) }
1157+
)
11271158
}
11281159
}
11291160

1161+
@Composable
1162+
private fun WatchActionPicker(
1163+
label: String,
1164+
currentKey: String,
1165+
onSelect: (String) -> Unit
1166+
) {
1167+
val options = com.eried.eucplanet.data.model.FlicAction.entries.map { action ->
1168+
action.name to stringResource(action.labelRes)
1169+
}
1170+
SimpleDropdown(
1171+
label = label,
1172+
currentKey = currentKey,
1173+
options = options,
1174+
onSelect = onSelect
1175+
)
1176+
}
1177+
11301178
@Composable
11311179
private fun SwitchSettingWithDesc(
11321180
label: String,

app/src/main/java/com/eried/eucplanet/ui/settings/SettingsViewModel.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ class SettingsViewModel @Inject constructor(
160160
fun updateWatchPwmDisplay(v: String) = update { copy(watchPwmDisplay = v) }
161161
fun updateWatchShowSpeedUnit(v: Boolean) = update { copy(watchShowSpeedUnit = v) }
162162
fun updateWatchEnableGpsSpeed(v: Boolean) = update { copy(watchEnableGpsSpeed = v) }
163+
fun updateWatchStem1Click(action: String) = update { copy(watchStem1Click = action) }
164+
fun updateWatchStem1Hold(action: String) = update { copy(watchStem1Hold = action) }
165+
fun updateWatchStem2Click(action: String) = update { copy(watchStem2Click = action) }
166+
fun updateWatchStem2Hold(action: String) = update { copy(watchStem2Hold = action) }
163167

164168
private val _ttsSwitchPrompt = MutableStateFlow<String?>(null)
165169
val ttsSwitchPrompt: StateFlow<String?> = _ttsSwitchPrompt.asStateFlow()

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

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

33
import android.util.Log
44
import com.eried.eucplanet.data.repository.WheelRepository
5+
import com.eried.eucplanet.flic.FlicManager
56
import com.google.android.gms.wearable.MessageEvent
67
import com.google.android.gms.wearable.WearableListenerService
78
import dagger.hilt.android.AndroidEntryPoint
@@ -27,17 +28,22 @@ class PhoneWearListenerService : WearableListenerService() {
2728
private const val CMD_HORN = "horn"
2829
private const val CMD_LIGHT_ON = "light_on"
2930
private const val CMD_LIGHT_OFF = "light_off"
31+
private const val ACTION_PREFIX = "action:"
3032
}
3133

3234
@Inject lateinit var wheelRepository: WheelRepository
35+
@Inject lateinit var flicManager: FlicManager
3336

3437
override fun onMessageReceived(event: MessageEvent) {
3538
if (event.path != PATH_CONTROL) return
3639
val command = String(event.data)
3740
Log.i(TAG, "control from watch: $command")
38-
when (command) {
39-
CMD_HORN -> wheelRepository.sendHorn()
40-
CMD_LIGHT_ON, CMD_LIGHT_OFF -> wheelRepository.toggleLight()
41+
when {
42+
command == CMD_HORN -> wheelRepository.sendHorn()
43+
command == CMD_LIGHT_ON || command == CMD_LIGHT_OFF ->
44+
wheelRepository.toggleLight()
45+
command.startsWith(ACTION_PREFIX) ->
46+
flicManager.dispatchActionByName(command.removePrefix(ACTION_PREFIX))
4147
else -> Log.w(TAG, "unknown control: $command")
4248
}
4349
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ class WearBridge @Inject constructor(
7979
private const val K_OPT_GAUGE_BAND = "wgb"
8080
private const val K_OPT_GAUGE_ORANGE = "wgo"
8181
private const val K_OPT_GAUGE_RED = "wgr"
82+
private const val K_STEM1_CLICK = "s1c"
83+
private const val K_STEM1_HOLD = "s1h"
84+
private const val K_STEM2_CLICK = "s2c"
85+
private const val K_STEM2_HOLD = "s2h"
8286

8387
private const val PATH_WAKE = "/euc/wake"
8488
}
@@ -175,6 +179,10 @@ class WearBridge @Inject constructor(
175179
dataMap.putBoolean(K_OPT_GAUGE_BAND, settings.showGaugeColorBand)
176180
dataMap.putInt(K_OPT_GAUGE_ORANGE, settings.gaugeOrangeThresholdPct)
177181
dataMap.putInt(K_OPT_GAUGE_RED, settings.gaugeRedThresholdPct)
182+
dataMap.putString(K_STEM1_CLICK, settings.watchStem1Click)
183+
dataMap.putString(K_STEM1_HOLD, settings.watchStem1Hold)
184+
dataMap.putString(K_STEM2_CLICK, settings.watchStem2Click)
185+
dataMap.putString(K_STEM2_HOLD, settings.watchStem2Hold)
178186
// DataItems dedupe by content. Bumping a timestamp guarantees
179187
// the watch sees every snapshot when the values stop changing
180188
// (e.g. wheel idle, but we want the connection-state heartbeat).

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@
103103
<string name="watch_show_speed_unit">Speed unit label</string>
104104
<string name="watch_show_speed_unit_desc">Show the unit label next to the big speed number</string>
105105

106+
<!-- Watch hardware buttons -->
107+
<string name="section_watch_buttons">Hardware buttons</string>
108+
<string name="watch_buttons_help">Map your watch\'s side buttons to wheel actions. On Galaxy Watch Ultra: button 1 = orange Action button, button 2 = lower side button (after assigning EUC Planet to launch on press in your watch settings).</string>
109+
<string name="watch_button_1">Button 1</string>
110+
<string name="watch_button_2">Button 2</string>
111+
<string name="watch_button_click_label">Click</string>
112+
<string name="watch_button_hold_label">Long press</string>
113+
106114
<!-- Settings: Backup -->
107115
<string name="section_cloud_folder">Backup folder</string>
108116
<string name="cloud_caption">Pick a folder on this phone where trip CSVs and a settings backup will be saved</string>

wear/src/main/java/com/eried/eucplanet/wear/MainActivity.kt

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import android.content.Intent
66
import android.content.IntentFilter
77
import android.content.pm.ApplicationInfo
88
import android.os.Bundle
9+
import android.view.KeyEvent
910
import android.view.WindowManager
1011
import androidx.activity.ComponentActivity
1112
import androidx.activity.compose.setContent
1213
import androidx.core.content.ContextCompat
1314
import androidx.lifecycle.lifecycleScope
15+
import com.eried.eucplanet.wear.bridge.WatchControl
1416
import com.eried.eucplanet.wear.bridge.WatchState
1517
import com.eried.eucplanet.wear.bridge.WatchStateRepository
1618
import com.eried.eucplanet.wear.ui.WatchApp
@@ -94,6 +96,78 @@ class MainActivity : ComponentActivity() {
9496
runCatching { unregisterReceiver(demoReceiver) }
9597
}
9698
}
99+
100+
/**
101+
* Watch hardware-button intercept. Galaxy Watch Ultra surfaces the orange
102+
* Action button as KEYCODE_STEM_1 and the lower side button as STEM_2
103+
* (after the user has bound EUC Planet to launch on press in the watch's
104+
* Customize-buttons menu). The bindings come from phone settings via the
105+
* Data Layer; we look them up at press time so live changes take effect.
106+
*/
107+
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
108+
val state = WatchStateRepository.state.value
109+
val action = when (keyCode) {
110+
KeyEvent.KEYCODE_STEM_1 -> state.stem1Click
111+
KeyEvent.KEYCODE_STEM_2 -> state.stem2Click
112+
else -> null
113+
}
114+
if (action == null || action == "NONE") return super.onKeyDown(keyCode, event)
115+
if (event?.repeatCount == 0) {
116+
// Mark as long-press-eligible so onKeyLongPress can intercept the
117+
// hold variant. KeyEvent.startTracking requires this to fire.
118+
event.startTracking()
119+
}
120+
return true
121+
}
122+
123+
override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean {
124+
val state = WatchStateRepository.state.value
125+
val action = when (keyCode) {
126+
KeyEvent.KEYCODE_STEM_1 -> state.stem1Hold
127+
KeyEvent.KEYCODE_STEM_2 -> state.stem2Hold
128+
else -> null
129+
}
130+
if (action == null || action == "NONE") return super.onKeyLongPress(keyCode, event)
131+
dispatchAction(action)
132+
return true
133+
}
134+
135+
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
136+
// If the press was tracking (long-press eligible) and DIDN'T trigger
137+
// a long-press handler, treat it as a click and dispatch the click
138+
// binding. Long-press already consumed the event in onKeyLongPress.
139+
if (event != null && (keyCode == KeyEvent.KEYCODE_STEM_1 || keyCode == KeyEvent.KEYCODE_STEM_2)) {
140+
if (event.isTracking && !event.isCanceled) {
141+
val state = WatchStateRepository.state.value
142+
val action = if (keyCode == KeyEvent.KEYCODE_STEM_1) state.stem1Click
143+
else state.stem2Click
144+
if (action != "NONE") {
145+
dispatchAction(action)
146+
return true
147+
}
148+
}
149+
}
150+
return super.onKeyUp(keyCode, event)
151+
}
152+
153+
/**
154+
* Dispatches a [com.eried.eucplanet.data.model.FlicAction] name. HORN and
155+
* LIGHT_TOGGLE use the existing dedicated control intents so older watch
156+
* builds without the action: prefix handler still work; everything else
157+
* goes through the prefixed passthrough that PhoneWearListenerService
158+
* forwards to FlicManager.dispatchActionByName().
159+
*/
160+
private fun dispatchAction(action: String) {
161+
val intent = when (action) {
162+
"HORN" -> WatchControl.HORN
163+
"LIGHT_TOGGLE" -> {
164+
val on = WatchStateRepository.state.value.lightOn
165+
if (on) WatchControl.LIGHT_OFF else WatchControl.LIGHT_ON
166+
}
167+
else -> WatchControl.ACTION_PREFIX + action
168+
}
169+
WatchStateRepository.sendControl(this, intent)
170+
}
97171
}
98172

99173
private fun Context.isDebuggable(): Boolean =

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ class WatchBridgeService : WearableListenerService() {
5757
showSpeedUnit = map.getBoolean(WatchKeys.OPT_SHOW_SPEED_UNIT, true),
5858
showGaugeBand = map.getBoolean(WatchKeys.OPT_GAUGE_BAND, false),
5959
gaugeOrangeThresholdPct = map.getInt(WatchKeys.OPT_GAUGE_ORANGE, 65),
60-
gaugeRedThresholdPct = map.getInt(WatchKeys.OPT_GAUGE_RED, 85)
60+
gaugeRedThresholdPct = map.getInt(WatchKeys.OPT_GAUGE_RED, 85),
61+
stem1Click = map.getString(WatchKeys.STEM1_CLICK, "HORN") ?: "HORN",
62+
stem1Hold = map.getString(WatchKeys.STEM1_HOLD, "NONE") ?: "NONE",
63+
stem2Click = map.getString(WatchKeys.STEM2_CLICK, "LIGHT_TOGGLE") ?: "LIGHT_TOGGLE",
64+
stem2Hold = map.getString(WatchKeys.STEM2_HOLD, "NONE") ?: "NONE"
6165
)
6266
)
6367
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,27 @@ object WatchKeys {
5656
const val OPT_GAUGE_ORANGE = "wgo"
5757
/** Phone's red-zone threshold percentage. */
5858
const val OPT_GAUGE_RED = "wgr"
59+
60+
// --- Hardware-button bindings (KEYCODE_STEM_1 / STEM_2). Stored as
61+
// FlicAction.name strings; "NONE" disables the binding. ---
62+
const val STEM1_CLICK = "s1c"
63+
const val STEM1_HOLD = "s1h"
64+
const val STEM2_CLICK = "s2c"
65+
const val STEM2_HOLD = "s2h"
5966
}
6067

6168
object WatchControl {
6269
const val HORN = "horn"
6370
const val LIGHT_ON = "light_on"
6471
const val LIGHT_OFF = "light_off"
72+
/**
73+
* Generic action passthrough: payload is a [FlicAction] name. Used by
74+
* the watch's stem-button handler when the bound action needs phone
75+
* routing (LOCK_TOGGLE, SAFETY_TOGGLE, RECORD_TOGGLE, VOICE_ANNOUNCE,
76+
* MEDIA_*). Horn / light keep their dedicated paths above for back-
77+
* compat with prior watch builds.
78+
*/
79+
const val ACTION_PREFIX = "action:"
6580
}
6681

6782
/**
@@ -114,5 +129,9 @@ data class WatchState(
114129
val showSpeedUnit: Boolean = true,
115130
val showGaugeBand: Boolean = false,
116131
val gaugeOrangeThresholdPct: Int = 65,
117-
val gaugeRedThresholdPct: Int = 85
132+
val gaugeRedThresholdPct: Int = 85,
133+
val stem1Click: String = "HORN",
134+
val stem1Hold: String = "NONE",
135+
val stem2Click: String = "LIGHT_TOGGLE",
136+
val stem2Hold: String = "NONE"
118137
)

0 commit comments

Comments
 (0)