Skip to content

Commit d80fbc6

Browse files
committed
v0.2.0: 4 Flic slots, dashboard polish, search highlighting
- Expand Flic support from 2 to 4 button slots; scan card moves after the paired buttons and hides once all 4 slots are filled; fix scan aborting after pairing by rediscovering already-known buttons silently - Dashboard: car-style P/D indicators top-left, tri-state GPS icon top-right (off/not-fixed/fixed), optimistic wheel lock flip with confirmation recheck - Voice button long-press menu: Turn on/off periodic announcements (persisted via voicePeriodicEnabled); menu labels now match the settings tab names - Settings: search highlighting covers all labels via shared helper - Bump Room schema to 21 and version to 0.2.0
1 parent 35a587c commit d80fbc6

24 files changed

Lines changed: 777 additions & 288 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 = 3
31-
versionName = "0.1.2"
30+
versionCode = 4
31+
versionName = "0.2.0"
3232

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

app/src/main/java/com/eried/eucplanet/data/db/AppDatabase.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import com.eried.eucplanet.data.model.TripRecord
88

99
@Database(
1010
entities = [AppSettings::class, TripRecord::class, AlarmRule::class],
11-
version = 20,
11+
version = 21,
1212
exportSchema = false
1313
)
1414
abstract class AppDatabase : RoomDatabase() {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ data class AppSettings(
2424

2525
// Voice
2626
val voiceEnabled: Boolean = true,
27+
// Independent toggle for the periodic (every N seconds) status announcements. When false,
28+
// voice still works for triggered events (manual button, Flic, alarms) but the periodic
29+
// loop is silent. Toggled from the dashboard via long-press on the Voice action.
30+
@ColumnInfo(defaultValue = "1")
31+
val voicePeriodicEnabled: Boolean = true,
2732
val voiceOnlyWhenConnected: Boolean = true,
2833
val voiceIntervalSeconds: Int = 30,
2934
val voiceSpeechRate: Float = 1.2f,
@@ -86,6 +91,30 @@ data class AppSettings(
8691
val flic2DoubleClick: String = "NONE",
8792
val flic2Hold: String = "SAFETY_ON",
8893

94+
// Flic button 3
95+
@ColumnInfo(defaultValue = "NULL")
96+
val flic3Address: String? = null,
97+
@ColumnInfo(defaultValue = "Button 3")
98+
val flic3Name: String = "Button 3",
99+
@ColumnInfo(defaultValue = "NONE")
100+
val flic3Click: String = "NONE",
101+
@ColumnInfo(defaultValue = "NONE")
102+
val flic3DoubleClick: String = "NONE",
103+
@ColumnInfo(defaultValue = "NONE")
104+
val flic3Hold: String = "NONE",
105+
106+
// Flic button 4
107+
@ColumnInfo(defaultValue = "NULL")
108+
val flic4Address: String? = null,
109+
@ColumnInfo(defaultValue = "Button 4")
110+
val flic4Name: String = "Button 4",
111+
@ColumnInfo(defaultValue = "NONE")
112+
val flic4Click: String = "NONE",
113+
@ColumnInfo(defaultValue = "NONE")
114+
val flic4DoubleClick: String = "NONE",
115+
@ColumnInfo(defaultValue = "NONE")
116+
val flic4Hold: String = "NONE",
117+
89118
// Auto-lights (sunset/sunrise based, uses live GPS from trip repository)
90119
val autoLightsEnabled: Boolean = false,
91120
val autoLightsOnMinutesBefore: Int = 30, // minutes before sunset to turn lights ON

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,17 +171,23 @@ class WheelRepository @Inject constructor(
171171
}
172172

173173
fun toggleLock() {
174-
val newState = !_locked.value
174+
val targetState = !_locked.value
175+
// Optimistic flip: the button reflects the requested state immediately so the user
176+
// doesn't mash it again. Telemetry (0x20 settings) is the final source of truth —
177+
// if the wheel reports back a different state, _locked is corrected then.
178+
_locked.value = targetState
175179
scope.launch {
176-
val success = authenticateAndLock(newState)
180+
val success = authenticateAndLock(targetState)
181+
// Re-read settings so the wheel can confirm (or override) the optimistic UI.
182+
delay(800)
183+
bleManager.writeCommand(InMotionV2Commands.getCurrentSettings())
177184
if (success) {
178-
_locked.value = newState
179185
val s = settingsRepository.get()
180186
if (s.announceWheelLock) {
181-
voiceService.announceEvent(context.getString(if (newState) R.string.voice_wheel_locked else R.string.voice_wheel_unlocked))
187+
voiceService.announceEvent(context.getString(if (targetState) R.string.voice_wheel_locked else R.string.voice_wheel_unlocked))
182188
}
183189
} else {
184-
Log.e(TAG, "Lock command failed: auth unsuccessful")
190+
Log.e(TAG, "Lock command failed: auth unsuccessful — awaiting wheel telemetry resync")
185191
}
186192
}
187193
}
@@ -197,7 +203,7 @@ class WheelRepository @Inject constructor(
197203
bleManager.writeCommand(InMotionV2Commands.requestAuthKey())
198204
Log.i(TAG, "Lock: requesting auth key...")
199205

200-
val key = withTimeoutOrNull(2000L) { keyDeferred.await() }
206+
val key = withTimeoutOrNull(4000L) { keyDeferred.await() }
201207
pendingAuthKeyDeferred = null
202208

203209
if (key == null) {
@@ -213,7 +219,7 @@ class WheelRepository @Inject constructor(
213219
bleManager.writeCommand(InMotionV2Commands.verifyAuth(key))
214220
Log.i(TAG, "Lock: verifying auth...")
215221

216-
val confirmed = withTimeoutOrNull(2000L) { confirmDeferred.await() } ?: false
222+
val confirmed = withTimeoutOrNull(4000L) { confirmDeferred.await() } ?: false
217223
pendingAuthConfirmDeferred = null
218224

219225
if (!confirmed) {

app/src/main/java/com/eried/eucplanet/flic/FlicManager.kt

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class FlicManager @Inject constructor(
6363
flic2Manager = Flic2Manager.initAndGetInstance(context, Handler(Looper.getMainLooper()))
6464
Log.i(TAG, "Flic2Manager initialized")
6565
reconnectPairedButtons()
66+
scope.launch { reconcileSettings() }
6667
} catch (e: Exception) {
6768
Log.e(TAG, "Failed to init Flic2Manager", e)
6869
}
@@ -79,17 +80,68 @@ class FlicManager @Inject constructor(
7980
Log.i(TAG, "Reconnected ${buttons.size} paired buttons")
8081
}
8182

83+
// Keep AppSettings.flic1..4Address in sync with the Flic2 lib's paired list.
84+
// Clears stale addresses (button forgotten outside the app) and auto-fills empty slots
85+
// with buttons the lib already knows — fixes the case where pairing data was lost but the
86+
// buttons remain known to the Flic lib.
87+
private suspend fun reconcileSettings() {
88+
val addrs = (flic2Manager?.buttons ?: return).map { it.bdAddr }.toSet()
89+
val settings = settingsRepository.get()
90+
var updated = settings
91+
if (updated.flic1Address != null && updated.flic1Address !in addrs) updated = updated.copy(flic1Address = null)
92+
if (updated.flic2Address != null && updated.flic2Address !in addrs) updated = updated.copy(flic2Address = null)
93+
if (updated.flic3Address != null && updated.flic3Address !in addrs) updated = updated.copy(flic3Address = null)
94+
if (updated.flic4Address != null && updated.flic4Address !in addrs) updated = updated.copy(flic4Address = null)
95+
for (addr in addrs) {
96+
val alreadyAssigned = addr == updated.flic1Address || addr == updated.flic2Address ||
97+
addr == updated.flic3Address || addr == updated.flic4Address
98+
if (alreadyAssigned) continue
99+
updated = when {
100+
updated.flic1Address == null -> updated.copy(flic1Address = addr)
101+
updated.flic2Address == null -> updated.copy(flic2Address = addr)
102+
updated.flic3Address == null -> updated.copy(flic3Address = addr)
103+
updated.flic4Address == null -> updated.copy(flic4Address = addr)
104+
else -> updated // all slots full
105+
}
106+
}
107+
if (updated != settings) {
108+
Log.i(TAG, "Reconciled Flic addresses: 1=${updated.flic1Address} 2=${updated.flic2Address} 3=${updated.flic3Address} 4=${updated.flic4Address}")
109+
settingsRepository.update(updated)
110+
}
111+
}
112+
82113
fun startScan() {
83114
val manager = flic2Manager ?: return
84115
_scanning.value = true
85116
_scanStatus.value = ""
86117

87118
manager.startScan(object : Flic2ScanCallback {
88119
override fun onDiscoveredAlreadyPairedButton(button: Flic2Button) {
120+
// Button is in the Flic2 lib's storage already. Two cases:
121+
// (1) It's also saved in our AppSettings (flic1/flic2) — just reconnect
122+
// silently and keep scanning, otherwise scanning for a second button
123+
// would abort the moment the first already-paired one is seen.
124+
// (2) Lib knows it but AppSettings lost it (e.g. DB reset) — complete the
125+
// pair flow so the address gets saved and the UI can leave scanning state.
89126
Log.i(TAG, "Already paired: ${button.bdAddr}")
90-
_scanStatus.value = context.getString(R.string.flic_status_found_paired)
91127
button.addListener(buttonListener)
92128
button.connect()
129+
_pairedButtons.value = flic2Manager?.buttons ?: emptyList()
130+
scope.launch {
131+
val s = settingsRepository.get()
132+
val knownInApp = button.bdAddr == s.flic1Address || button.bdAddr == s.flic2Address ||
133+
button.bdAddr == s.flic3Address || button.bdAddr == s.flic4Address
134+
if (!knownInApp) {
135+
saveButtonAddress(button.bdAddr)
136+
_scanStatus.value = context.getString(
137+
R.string.flic_status_paired_fmt,
138+
button.name ?: button.bdAddr
139+
)
140+
_scanning.value = false
141+
flic2Manager?.stopScan()
142+
}
143+
// If known in app, leave the scan running so another button can be added.
144+
}
93145
}
94146

95147
override fun onDiscovered(bdAddr: String) {
@@ -135,22 +187,31 @@ class FlicManager @Inject constructor(
135187
flic2Manager?.forgetButton(button)
136188
_pairedButtons.value = flic2Manager?.buttons ?: emptyList()
137189
scope.launch {
138-
val settings = settingsRepository.get()
139-
if (settings.flic1Address == button.bdAddr) {
140-
settingsRepository.update(settings.copy(flic1Address = null))
141-
} else if (settings.flic2Address == button.bdAddr) {
142-
settingsRepository.update(settings.copy(flic2Address = null))
190+
val s = settingsRepository.get()
191+
val updated = when (button.bdAddr) {
192+
s.flic1Address -> s.copy(flic1Address = null)
193+
s.flic2Address -> s.copy(flic2Address = null)
194+
s.flic3Address -> s.copy(flic3Address = null)
195+
s.flic4Address -> s.copy(flic4Address = null)
196+
else -> s
143197
}
198+
if (updated !== s) settingsRepository.update(updated)
144199
}
145200
}
146201

147202
private suspend fun saveButtonAddress(bdAddr: String) {
148-
val settings = settingsRepository.get()
149-
if (settings.flic1Address == null) {
150-
settingsRepository.update(settings.copy(flic1Address = bdAddr))
151-
} else if (settings.flic2Address == null && settings.flic1Address != bdAddr) {
152-
settingsRepository.update(settings.copy(flic2Address = bdAddr))
203+
val s = settingsRepository.get()
204+
// Skip if this button is already assigned to any slot.
205+
if (bdAddr == s.flic1Address || bdAddr == s.flic2Address ||
206+
bdAddr == s.flic3Address || bdAddr == s.flic4Address) return
207+
val updated = when {
208+
s.flic1Address == null -> s.copy(flic1Address = bdAddr)
209+
s.flic2Address == null -> s.copy(flic2Address = bdAddr)
210+
s.flic3Address == null -> s.copy(flic3Address = bdAddr)
211+
s.flic4Address == null -> s.copy(flic4Address = bdAddr)
212+
else -> return // all slots full
153213
}
214+
settingsRepository.update(updated)
154215
}
155216

156217
// --- Button event handling ---
@@ -173,19 +234,17 @@ class FlicManager @Inject constructor(
173234

174235
private suspend fun dispatchAction(bdAddr: String, gesture: String) {
175236
val settings = settingsRepository.get()
176-
val actionName = when {
177-
bdAddr == settings.flic1Address -> when (gesture) {
178-
"click" -> settings.flic1Click
179-
"doubleClick" -> settings.flic1DoubleClick
180-
"hold" -> settings.flic1Hold
181-
else -> return
182-
}
183-
bdAddr == settings.flic2Address -> when (gesture) {
184-
"click" -> settings.flic2Click
185-
"doubleClick" -> settings.flic2DoubleClick
186-
"hold" -> settings.flic2Hold
187-
else -> return
188-
}
237+
val (click, dbl, hold) = when (bdAddr) {
238+
settings.flic1Address -> Triple(settings.flic1Click, settings.flic1DoubleClick, settings.flic1Hold)
239+
settings.flic2Address -> Triple(settings.flic2Click, settings.flic2DoubleClick, settings.flic2Hold)
240+
settings.flic3Address -> Triple(settings.flic3Click, settings.flic3DoubleClick, settings.flic3Hold)
241+
settings.flic4Address -> Triple(settings.flic4Click, settings.flic4DoubleClick, settings.flic4Hold)
242+
else -> return
243+
}
244+
val actionName = when (gesture) {
245+
"click" -> click
246+
"doubleClick" -> dbl
247+
"hold" -> hold
189248
else -> return
190249
}
191250

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ class VoiceService @Inject constructor(
289289
}
290290

291291
fun announceTrigger(data: WheelData, settings: AppSettings, isRecording: Boolean = false) {
292+
warnIfLowVolume(settings.voiceOutputChannel)
292293
// Drop immediately if a trigger is already in flight/queued; never queue more than one.
293294
if (triggerInFlight) return
294295
val parts = buildReportParts(data, settings, isRecording, periodic = false)
@@ -297,6 +298,32 @@ class VoiceService @Inject constructor(
297298
rate = settings.voiceSpeechRate, localeTag = settings.voiceLocale)
298299
}
299300

301+
private fun streamTypeFor(channel: String): Int = when (channel) {
302+
"NOTIFICATION" -> AudioManager.STREAM_NOTIFICATION
303+
"ALARM" -> AudioManager.STREAM_ALARM
304+
else -> AudioManager.STREAM_MUSIC
305+
}
306+
307+
fun warnIfLowVolume(channel: String) {
308+
try {
309+
val stream = streamTypeFor(channel)
310+
val max = audioManager.getStreamMaxVolume(stream)
311+
if (max <= 0) return
312+
val cur = audioManager.getStreamVolume(stream)
313+
val pct = cur.toFloat() / max.toFloat()
314+
if (pct <= 0.20f) {
315+
scope.launch {
316+
android.widget.Toast.makeText(
317+
context,
318+
context.getString(R.string.voice_volume_low_toast),
319+
android.widget.Toast.LENGTH_SHORT
320+
).show()
321+
}
322+
}
323+
} catch (_: Exception) {
324+
}
325+
}
326+
300327
private fun buildReportParts(
301328
data: WheelData, settings: AppSettings, isRecording: Boolean, periodic: Boolean
302329
): List<String> {
@@ -382,6 +409,7 @@ class VoiceService @Inject constructor(
382409
}
383410

384411
fun testSpeak(text: String, speechRate: Float, localeTag: String) {
412+
warnIfLowVolume(currentOutputChannel)
385413
speakInternal(text, isTrigger = false, rate = speechRate, localeTag = localeTag)
386414
}
387415

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ class WheelService : LifecycleService() {
274274
while (true) {
275275
val settings = settingsRepository.get()
276276
delay(settings.voiceIntervalSeconds * 1000L)
277-
if (settings.voiceEnabled) {
277+
if (settings.voiceEnabled && settings.voicePeriodicEnabled) {
278278
val connected = wheelRepository.connectionState.value == ConnectionState.CONNECTED
279279
if (settings.voiceOnlyWhenConnected && !connected) continue
280280
val data = wheelRepository.wheelData.value

app/src/main/java/com/eried/eucplanet/ui/common/InfoHint.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ fun InfoHint(
3333
modifier = Modifier.size(18.dp)
3434
)
3535
Text(
36-
text,
36+
highlightMatches(text, LocalSettingsSearchQuery.current),
3737
style = MaterialTheme.typography.bodyMedium,
3838
color = MaterialTheme.colorScheme.onSurfaceVariant,
3939
fontStyle = FontStyle.Italic
@@ -49,7 +49,7 @@ fun HintText(
4949
textAlign: TextAlign? = null
5050
) {
5151
Text(
52-
text,
52+
highlightMatches(text, LocalSettingsSearchQuery.current),
5353
modifier = modifier,
5454
style = if (small) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
5555
color = MaterialTheme.colorScheme.onSurfaceVariant,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.eried.eucplanet.ui.common
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.compositionLocalOf
5+
import androidx.compose.ui.graphics.Color
6+
import androidx.compose.ui.text.AnnotatedString
7+
import androidx.compose.ui.text.SpanStyle
8+
import androidx.compose.ui.text.buildAnnotatedString
9+
10+
val LocalSettingsSearchQuery = compositionLocalOf { "" }
11+
12+
@Composable
13+
fun highlightMatches(text: String, query: String): AnnotatedString {
14+
val q = query.trim()
15+
if (q.isEmpty()) return AnnotatedString(text)
16+
val highlightBg = Color(0xFFFFEB3B).copy(alpha = 0.55f)
17+
val highlightFg = Color.Black
18+
return buildAnnotatedString {
19+
append(text)
20+
var start = 0
21+
while (true) {
22+
val idx = text.indexOf(q, start, ignoreCase = true)
23+
if (idx < 0) break
24+
addStyle(SpanStyle(background = highlightBg, color = highlightFg), idx, idx + q.length)
25+
start = idx + q.length
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)