budctla41a513
android: the Gestures and Earbud settings pages
Step 5 of docs/android.md: the last two of the desktop's pages, after gestures.rs and earbuds.rs. - Gestures: each bud's taps and hold, the action in a pill; touching a row lists the actions the buds offer for that gesture, the one it has filled, and a pick is written at once. A pick made while a write is on its way goes after it, the latest winning; a failure shows what the buds kept. The hold cycle's modes are switches, and the last two stay: a hold needs two modes to step through. - Earbud settings: dual connection, the devices the buds keep (the one that asked is "this phone" here), Find my earbuds after "Ring your buds?" with a blinking light while they ring, and the name, firmware and codec. - The Overview's last card opens both pages too. - The row of page words scrolls the open one into view once it has grown: five words no longer fit a phone's width. Checked on the emulator with the demo buds: an action picked, a mode added to the hold cycle, ringing and the question before it.
+382 −4
7 files changed
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +96 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +9 −3
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Earbuds.kt +88 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Gestures.kt +160 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +4 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +23 −0
- M docs/android.md +2 −1
| @@ -12,6 +12,8 @@ import org.json.JSONObject | |||
| 12 | 12 | enum class Page(val word: String, val caption: String) { | |
| 13 | 13 | Overview("overview", "overview · battery, noise control, sound"), | |
| 14 | 14 | Equalizer("eq", "equalizer · presets, and the bands of yours"), | |
| 15 | + | Gestures("gestures", "gestures · taps and holds on each bud"), | |
| 16 | + | Earbuds("buds", "earbud settings · connection, find, firmware"), | |
| 15 | 17 | App("app", "app · colors, motion, debug log"), | |
| 16 | 18 | } | |
| 17 | 19 | ||
| @@ -29,8 +31,20 @@ sealed interface Item { | |||
| 29 | 31 | data class Preset(val id: Int) : Item | |
| 30 | 32 | ||
| 31 | 33 | data object NewPreset : Item | |
| 34 | + | ||
| 35 | + | /** A gesture of one bud given an action. */ | |
| 36 | + | data class Gesture(val side: String, val gesture: String) : Item | |
| 37 | + | ||
| 38 | + | /** A mode put in the hold cycle or taken out. */ | |
| 39 | + | data class Cycle(val mode: String) : Item | |
| 40 | + | ||
| 41 | + | /** The buds told to ring, or to stop. */ | |
| 42 | + | data object Find : Item | |
| 32 | 43 | } | |
| 33 | 44 | ||
| 45 | + | /** An action picked for a gesture on the screen, not on the buds yet. */ | |
| 46 | + | data class TouchDraft(val side: String, val gesture: String, val action: String) | |
| 47 | + | ||
| 34 | 48 | /** A custom preset's bands as moved on the screen, not saved on the buds yet. */ | |
| 35 | 49 | data class Draft(val preset: Int, val bands: List<EqBand>) | |
| 36 | 50 | ||
| @@ -98,6 +112,17 @@ class App( | |||
| 98 | 112 | var prompt by mutableStateOf<NamePrompt?>(null) | |
| 99 | 113 | private set | |
| 100 | 114 | ||
| 115 | + | var touchDraft by mutableStateOf<TouchDraft?>(null) | |
| 116 | + | private set | |
| 117 | + | ||
| 118 | + | /** The buds were told to ring and not yet to stop. */ | |
| 119 | + | var ringing by mutableStateOf(false) | |
| 120 | + | private set | |
| 121 | + | ||
| 122 | + | /** "Ring your buds?" is open. */ | |
| 123 | + | var confirmFind by mutableStateOf(false) | |
| 124 | + | private set | |
| 125 | + | ||
| 101 | 126 | /** The commands sent and not answered yet, oldest first: each gets one event, in order. */ | |
| 102 | 127 | private val inFlight = mutableStateListOf<Item>() | |
| 103 | 128 | ||
| @@ -235,6 +260,42 @@ class App( | |||
| 235 | 260 | } | |
| 236 | 261 | } | |
| 237 | 262 | ||
| 263 | + | /** Gives a gesture an action, written at once. A pick made while a write is on its way goes after it. */ | |
| 264 | + | fun pickAction(side: String, gesture: String, action: String) { | |
| 265 | + | touchDraft = TouchDraft(side, gesture, action) | |
| 266 | + | saveTouch() | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | private fun saveTouch() { | |
| 270 | + | val draft = touchDraft ?: return | |
| 271 | + | when { | |
| 272 | + | buds.action(draft.side, draft.gesture) == draft.action -> touchDraft = null | |
| 273 | + | Item.Gesture(draft.side, draft.gesture) !in inFlight -> command( | |
| 274 | + | Item.Gesture(draft.side, draft.gesture), | |
| 275 | + | JSONObject().put("command", "touch").put("side", draft.side).put("gesture", draft.gesture).put("action", draft.action), | |
| 276 | + | ) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /** Rings the buds, after "Ring your buds?" (the sound is loud), or stops them. */ | |
| 281 | + | fun find() { | |
| 282 | + | if (ringing) command(Item.Find, JSONObject().put("command", "find").put("ring", false)) else confirmFind = true | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | fun answerFind(ring: Boolean) { | |
| 286 | + | confirmFind = false | |
| 287 | + | if (ring) command(Item.Find, JSONObject().put("command", "find").put("ring", true)) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /** Puts a mode in the hold cycle or takes it out. The buds need two modes to step through. */ | |
| 291 | + | fun cycle(mode: String, on: Boolean) { | |
| 292 | + | val now = buds.touch?.holdCycle ?: return | |
| 293 | + | val modes = now.filter { it != mode } + if (on) listOf(mode) else emptyList() | |
| 294 | + | if (modes.size >= 2 && inFlight.none { it is Item.Cycle }) { | |
| 295 | + | command(Item.Cycle(mode), JSONObject().put("command", "hold-cycle").put("modes", JSONArray(modes))) | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 238 | 299 | private fun customEq(preset: EqPreset, bands: List<EqBand>) = JSONObject() | |
| 239 | 300 | .put("command", "custom-eq") | |
| 240 | 301 | .put("preset", Buds.key(preset)) | |
| @@ -296,6 +357,22 @@ class App( | |||
| 296 | 357 | // A new preset opens its bands: it is the one in use. | |
| 297 | 358 | if (item == Item.NewPreset) editing = buds.inUse()?.id | |
| 298 | 359 | } | |
| 360 | + | "touch" -> { | |
| 361 | + | val gestures = event.getJSONArray("gestures").let { a -> List(a.length()) { parseGesture(a.getJSONObject(it)) } } | |
| 362 | + | buds = buds.copy(touch = buds.touch?.copy(gestures = gestures)) | |
| 363 | + | connection = Connection.Live | |
| 364 | + | // An action picked while this write was on its way goes out now. | |
| 365 | + | if (item is Item.Gesture) saveTouch() | |
| 366 | + | } | |
| 367 | + | "ringing" -> { | |
| 368 | + | ringing = event.getBoolean("ring") | |
| 369 | + | connection = Connection.Live | |
| 370 | + | } | |
| 371 | + | "hold-cycle" -> { | |
| 372 | + | val modes = event.getJSONArray("modes").let { a -> List(a.length()) { a.getString(it) } } | |
| 373 | + | buds = buds.copy(touch = buds.touch?.copy(holdCycle = modes)) | |
| 374 | + | connection = Connection.Live | |
| 375 | + | } | |
| 299 | 376 | "bass" -> { | |
| 300 | 377 | val level = event.getInt("level") | |
| 301 | 378 | buds = buds.copy(bass = level) | |
| @@ -307,7 +384,9 @@ class App( | |||
| 307 | 384 | connection = Connection.Failed(kind, event.getString("message")) | |
| 308 | 385 | if (kind == "not-found") buds = buds.copy(connected = false) | |
| 309 | 386 | if (item == Item.Bass) bassDraft = null | |
| 387 | + | // The buds keep what they had: show that, not what was picked. | |
| 310 | 388 | if (item is Item.Preset && draft?.preset == item.id) draft = null | |
| 389 | + | if (item is Item.Gesture && touchDraft?.let { Item.Gesture(it.side, it.gesture) } == item) touchDraft = null | |
| 311 | 390 | } | |
| 312 | 391 | } | |
| 313 | 392 | } | |
| @@ -333,6 +412,23 @@ class App( | |||
| 333 | 412 | bassDraft = null | |
| 334 | 413 | } | |
| 335 | 414 | is Item.Preset, Item.NewPreset -> pretendEq(json) | |
| 415 | + | is Item.Gesture -> { | |
| 416 | + | val action = json.getString("action") | |
| 417 | + | val touch = buds.touch ?: return | |
| 418 | + | buds = buds.copy( | |
| 419 | + | touch = touch.copy( | |
| 420 | + | gestures = touch.gestures.map { | |
| 421 | + | if (it.side == item.side && it.gesture == item.gesture && it.group == null) it.copy(action = action) else it | |
| 422 | + | }, | |
| 423 | + | ), | |
| 424 | + | ) | |
| 425 | + | touchDraft = null | |
| 426 | + | } | |
| 427 | + | is Item.Cycle -> { | |
| 428 | + | val modes = json.getJSONArray("modes").let { a -> List(a.length()) { a.getString(it) } } | |
| 429 | + | buds = buds.copy(touch = buds.touch?.copy(holdCycle = modes)) | |
| 430 | + | } | |
| 431 | + | Item.Find -> ringing = json.getBoolean("ring") | |
| 336 | 432 | } | |
| 337 | 433 | } | |
| 338 | 434 | ||
| @@ -70,6 +70,10 @@ data class Buds( | |||
| 70 | 70 | return shownName(preset) | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | /** What a gesture of a bud (`left`, `right`) does now. */ | |
| 74 | + | fun action(side: String, gesture: String) = | |
| 75 | + | touch?.gestures?.find { it.side == side && it.gesture == gesture && it.group == null }?.action | |
| 76 | + | ||
| 73 | 77 | /** The preset with this id. */ | |
| 74 | 78 | fun preset(id: Int) = presets?.presets?.find { it.id == id } | |
| 75 | 79 | ||
| @@ -197,6 +201,10 @@ internal fun parseNoise(json: JSONObject) = NoiseControl(json.getString("mode"), | |||
| 197 | 201 | /** A switch, as a reading and the `switch` event give it. */ | |
| 198 | 202 | internal fun parseSwitch(json: JSONObject) = Switch(json.getString("key"), json.getString("label"), json.getBoolean("on")) | |
| 199 | 203 | ||
| 204 | + | /** A gesture and its action, as a reading and the `touch` event give it. */ | |
| 205 | + | internal fun parseGesture(json: JSONObject) = | |
| 206 | + | Gesture(json.getString("side"), json.getString("gesture"), json.getString("action"), if (json.isNull("group")) null else json.getInt("group")) | |
| 207 | + | ||
| 200 | 208 | /** An EQ preset, as a reading and the `custom-eq` event give it. */ | |
| 201 | 209 | internal fun parsePreset(json: JSONObject) = EqPreset( | |
| 202 | 210 | json.getString("name"), | |
| @@ -210,9 +218,7 @@ internal fun parsePreset(json: JSONObject) = EqPreset( | |||
| 210 | 218 | internal fun parsePresets(json: JSONObject) = EqPresets(json.text("current"), json.optJSONArray("presets").objects().map(::parsePreset)) | |
| 211 | 219 | ||
| 212 | 220 | private fun touch(json: JSONObject) = TouchControls( | |
| 213 | − | gestures = json.optJSONArray("gestures").objects().map { | |
| 214 | − | Gesture(it.getString("side"), it.getString("gesture"), it.getString("action"), if (it.isNull("group")) null else it.getInt("group")) | |
| 215 | − | }, | |
| 221 | + | gestures = json.optJSONArray("gestures").objects().map(::parseGesture), | |
| 216 | 222 | holdCycle = json.optJSONArray("hold_cycle")?.strings(), | |
| 217 | 223 | choices = json.optJSONArray("choices").objects().map { GestureChoice(it.getString("gesture"), it.optJSONArray("actions").strings()) }, | |
| 218 | 224 | cycleModes = json.optJSONArray("cycle_modes").strings(), | |
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package io.github.krolyxon.budctl | |
| 2 | + | ||
| 3 | + | import androidx.compose.foundation.layout.Arrangement | |
| 4 | + | import androidx.compose.foundation.layout.Row | |
| 5 | + | import androidx.compose.runtime.Composable | |
| 6 | + | import androidx.compose.ui.Alignment | |
| 7 | + | import androidx.compose.ui.unit.dp | |
| 8 | + | ||
| 9 | + | /** The switch for dual connection, by its key. */ | |
| 10 | + | private const val DUAL = "dual" | |
| 11 | + | ||
| 12 | + | /** | |
| 13 | + | * The Earbud settings page (crates/budctl-gui/src/earbuds.rs): dual connection and its devices, | |
| 14 | + | * find the buds, and what they run. The device that asked the buds for their list is this phone. | |
| 15 | + | */ | |
| 16 | + | @Composable | |
| 17 | + | fun Earbuds(app: App) { | |
| 18 | + | val buds = app.buds | |
| 19 | + | buds.switches.find { it.key == DUAL }?.let { dual -> | |
| 20 | + | Heading("connection") | |
| 21 | + | Card { | |
| 22 | + | SettingRow( | |
| 23 | + | Icon.Dual, | |
| 24 | + | "Dual connection", | |
| 25 | + | when { | |
| 26 | + | app.saving(Item.Switch(DUAL)) -> SAVING | |
| 27 | + | dual.on -> "Your buds stay connected to two devices at once, like this phone and your computer" | |
| 28 | + | else -> "Your buds connect to one device at a time" | |
| 29 | + | }, | |
| 30 | + | onClick = { app.switch(DUAL, !dual.on) }, | |
| 31 | + | ) { Toggle(dual.on) } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | buds.dual?.let { Devices(it) } | |
| 35 | + | Heading("find my earbuds") | |
| 36 | + | Card { | |
| 37 | + | val about = when { | |
| 38 | + | app.saving(Item.Find) -> if (app.ringing) "Stopping…" else "Starting…" | |
| 39 | + | app.ringing -> "Ringing: stop it once you have found them" | |
| 40 | + | else -> "Plays a loud sound from both buds" | |
| 41 | + | } | |
| 42 | + | SettingRow(Icon.Find, "Find my earbuds", about) { | |
| 43 | + | Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 44 | + | // A blinking live dot while the buds ring. | |
| 45 | + | if (app.ringing) Light(LocalLook.current.p.live, 8.dp, blinking = true) | |
| 46 | + | Pill(if (app.ringing) "Stop" else "Ring", filled = app.ringing, onClick = app::find) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | Heading("about these buds") | |
| 51 | + | Card { | |
| 52 | + | SettingRow(Icon.Earbud, "Name") { ValueChip(buds.name) } | |
| 53 | + | SettingRow(Icon.Chip, "Firmware") { ValueChip(buds.firmware ?: "unknown") } | |
| 54 | + | SettingRow(Icon.Info, "Audio codec") { ValueChip(buds.codec ?: "unknown") } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * The devices dual connection keeps, as the buds list them. Nothing here can be changed: the buds | |
| 60 | + | * take no command to connect, disconnect or forget one. | |
| 61 | + | */ | |
| 62 | + | @Composable | |
| 63 | + | private fun Devices(list: List<DualDevice>) { | |
| 64 | + | Heading("devices") | |
| 65 | + | if (list.isEmpty()) { | |
| 66 | + | Note("The buds list no devices.") | |
| 67 | + | } else { | |
| 68 | + | Card { | |
| 69 | + | for (device in list) { | |
| 70 | + | SettingRow( | |
| 71 | + | if (device.thisDevice) Icon.Phone else Icon.Laptop, | |
| 72 | + | device.name.ifEmpty { "Unnamed device" }, | |
| 73 | + | state(device) + "\n" + device.address, | |
| 74 | + | current = device.connected, | |
| 75 | + | ) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | Note("These buds cannot connect, disconnect or forget a device from here.") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** "Connected · this phone", "Not connected"… */ | |
| 83 | + | private fun state(device: DualDevice) = buildList { | |
| 84 | + | add(if (device.connected) "Connected" else "Not connected") | |
| 85 | + | if (device.thisDevice) add("this phone") | |
| 86 | + | if (device.mainAudio) add("main audio") | |
| 87 | + | if (device.playing) add("playing") | |
| 88 | + | }.joinToString(" · ") | |
| @@ -0,0 +1,160 @@ | |||
| 1 | + | package io.github.krolyxon.budctl | |
| 2 | + | ||
| 3 | + | import androidx.compose.animation.animateColorAsState | |
| 4 | + | import androidx.compose.foundation.background | |
| 5 | + | import androidx.compose.foundation.clickable | |
| 6 | + | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 7 | + | import androidx.compose.foundation.layout.Arrangement | |
| 8 | + | import androidx.compose.foundation.layout.Box | |
| 9 | + | import androidx.compose.foundation.layout.Column | |
| 10 | + | import androidx.compose.foundation.layout.Row | |
| 11 | + | import androidx.compose.foundation.layout.fillMaxWidth | |
| 12 | + | import androidx.compose.foundation.layout.padding | |
| 13 | + | import androidx.compose.foundation.layout.widthIn | |
| 14 | + | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 15 | + | import androidx.compose.runtime.Composable | |
| 16 | + | import androidx.compose.runtime.CompositionLocalProvider | |
| 17 | + | import androidx.compose.runtime.getValue | |
| 18 | + | import androidx.compose.runtime.mutableStateOf | |
| 19 | + | import androidx.compose.runtime.remember | |
| 20 | + | import androidx.compose.runtime.setValue | |
| 21 | + | import androidx.compose.ui.Alignment | |
| 22 | + | import androidx.compose.ui.Modifier | |
| 23 | + | import androidx.compose.ui.draw.clip | |
| 24 | + | import androidx.compose.ui.draw.rotate | |
| 25 | + | import androidx.compose.ui.graphics.Color | |
| 26 | + | import androidx.compose.ui.text.font.FontWeight | |
| 27 | + | import androidx.compose.ui.unit.dp | |
| 28 | + | import androidx.compose.ui.unit.sp | |
| 29 | + | import androidx.compose.ui.window.Dialog | |
| 30 | + | ||
| 31 | + | /** The buds, as the page shows them. */ | |
| 32 | + | private val SIDES = listOf("left" to "Left bud", "right" to "Right bud") | |
| 33 | + | ||
| 34 | + | /** A gesture whose action is being picked. */ | |
| 35 | + | private data class Picking(val side: String, val gesture: String, val actions: List<String>) | |
| 36 | + | ||
| 37 | + | /** The Gestures page (crates/budctl-gui/src/gestures.rs): what each tap and hold does, and what a hold steps through. */ | |
| 38 | + | @Composable | |
| 39 | + | fun Gestures(app: App) { | |
| 40 | + | val touch = app.buds.touch | |
| 41 | + | if (touch == null) { | |
| 42 | + | Note("These buds did not report their touch controls.") | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | var picking by remember { mutableStateOf<Picking?>(null) } | |
| 46 | + | for ((side, heading) in SIDES) { | |
| 47 | + | Heading(heading) | |
| 48 | + | Card { | |
| 49 | + | for (choice in touch.choices) { | |
| 50 | + | val draft = app.touchDraft?.takeIf { it.side == side && it.gesture == choice.gesture } | |
| 51 | + | val shown = draft?.action ?: app.buds.action(side, choice.gesture) ?: "none" | |
| 52 | + | SettingRow( | |
| 53 | + | if (choice.gesture == "hold") Icon.Earbud else Icon.Gestures, | |
| 54 | + | gestureName(choice.gesture), | |
| 55 | + | if (app.saving(Item.Gesture(side, choice.gesture))) SAVING else null, | |
| 56 | + | onClick = { picking = Picking(side, choice.gesture, choice.actions) }, | |
| 57 | + | ) { ActionPill(actionName(shown)) } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | val cycle = touch.holdCycle | |
| 62 | + | if (cycle != null && touch.cycleModes.isNotEmpty()) { | |
| 63 | + | Heading("A hold steps through") | |
| 64 | + | Card { | |
| 65 | + | for (mode in touch.cycleModes) { | |
| 66 | + | val on = mode in cycle | |
| 67 | + | val about = when { | |
| 68 | + | app.saving(Item.Cycle(mode)) -> SAVING | |
| 69 | + | on && cycle.size <= 2 -> "A hold needs two modes to step through" | |
| 70 | + | else -> null | |
| 71 | + | } | |
| 72 | + | SettingRow(modeIcon(mode), modeName(mode), about, onClick = { app.cycle(mode, !on) }) { Toggle(on) } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | picking?.let { asked -> | |
| 77 | + | val now = app.touchDraft?.takeIf { it.side == asked.side && it.gesture == asked.gesture }?.action | |
| 78 | + | ?: app.buds.action(asked.side, asked.gesture) | |
| 79 | + | ActionList(asked, now, close = { picking = null }) { action -> app.pickAction(asked.side, asked.gesture, action) } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The action a gesture has, in a pill with a chevron: the row opens the list. */ | |
| 84 | + | @Composable | |
| 85 | + | private fun ActionPill(words: String) { | |
| 86 | + | val p = LocalLook.current.p | |
| 87 | + | Row( | |
| 88 | + | Modifier | |
| 89 | + | .widthIn(max = 170.dp) | |
| 90 | + | .clip(RoundedCornerShape(percent = 50)) | |
| 91 | + | .background(p.s2) | |
| 92 | + | .padding(start = 16.dp, end = 10.dp, top = 9.dp, bottom = 9.dp), | |
| 93 | + | verticalAlignment = Alignment.CenterVertically, | |
| 94 | + | horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 95 | + | ) { | |
| 96 | + | Words(words, 14.sp, weight = FontWeight.Medium, modifier = Modifier.weight(1f, fill = false)) | |
| 97 | + | Glyph(Icon.Chevron, 14.dp, p.grey, Modifier.rotate(90f)) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /** The actions a gesture can be given, over the dimmed page; the one it has is filled. */ | |
| 102 | + | @Composable | |
| 103 | + | private fun ActionList(asked: Picking, now: String?, close: () -> Unit, pick: (String) -> Unit) { | |
| 104 | + | val look = LocalLook.current | |
| 105 | + | val p = look.p | |
| 106 | + | Dialog(onDismissRequest = close) { | |
| 107 | + | CompositionLocalProvider(LocalLook provides look) { | |
| 108 | + | Column( | |
| 109 | + | Modifier | |
| 110 | + | .clip(RoundedCornerShape(28.dp)) | |
| 111 | + | .background(p.s1) | |
| 112 | + | .padding(vertical = 20.dp, horizontal = 12.dp), | |
| 113 | + | verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 114 | + | ) { | |
| 115 | + | Label( | |
| 116 | + | "${SIDES.first { it.first == asked.side }.second} · ${gestureName(asked.gesture)}", | |
| 117 | + | modifier = Modifier.padding(start = 12.dp, bottom = 10.dp), | |
| 118 | + | ) | |
| 119 | + | for (action in asked.actions) { | |
| 120 | + | val chosen = action == now | |
| 121 | + | val fill by animateColorAsState(if (chosen) p.fill else Color.Transparent, Motion.eased(Motion.STATE, look.motion), label = "action") | |
| 122 | + | Box( | |
| 123 | + | Modifier | |
| 124 | + | .fillMaxWidth() | |
| 125 | + | .clip(RoundedCornerShape(20.dp)) | |
| 126 | + | .background(fill) | |
| 127 | + | .clickable(remember { MutableInteractionSource() }, indication = null) { | |
| 128 | + | pick(action) | |
| 129 | + | close() | |
| 130 | + | } | |
| 131 | + | .padding(horizontal = 16.dp, vertical = 13.dp), | |
| 132 | + | ) { | |
| 133 | + | Words(actionName(action), 15.sp, if (chosen) p.fillFg else p.fg, FontWeight.Medium) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | private fun gestureName(gesture: String) = if (gesture == "hold") "Touch and hold" else Buds.words(gesture) | |
| 142 | + | ||
| 143 | + | /** An action, as people read it. */ | |
| 144 | + | fun actionName(action: String) = when (action) { | |
| 145 | + | "none" -> "Nothing" | |
| 146 | + | "play-pause" -> "Play / pause" | |
| 147 | + | "next" -> "Next track" | |
| 148 | + | "previous" -> "Previous track" | |
| 149 | + | "voice-assistant" -> "Voice assistant" | |
| 150 | + | "noise-cycle" -> "Noise control" | |
| 151 | + | else -> Buds.words(action) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | private fun modeName(mode: String) = if (mode == "anc") "Noise cancelling" else Buds.words(mode) | |
| 155 | + | ||
| 156 | + | private fun modeIcon(mode: String) = when (mode) { | |
| 157 | + | "off" -> Icon.NoiseOff | |
| 158 | + | "anc" -> Icon.Anc | |
| 159 | + | else -> Icon.Transparency | |
| 160 | + | } | |
| @@ -31,6 +31,10 @@ fun Overview(app: App, askPermission: () -> Unit, open: (Page) -> Unit) { | |||
| 31 | 31 | private fun More(app: App, open: (Page) -> Unit) { | |
| 32 | 32 | Card { | |
| 33 | 33 | SettingRow(Icon.Equalizer, "Equalizer", app.buds.eqName(), onClick = { open(Page.Equalizer) }) { Chevron() } | |
| 34 | + | SettingRow(Icon.Gestures, "Gestures", "Taps and holds on each bud", onClick = { open(Page.Gestures) }) { Chevron() } | |
| 35 | + | SettingRow(Icon.Earbud, "Earbud settings", "Dual connection, find my earbuds, firmware", onClick = { open(Page.Earbuds) }) { | |
| 36 | + | Chevron() | |
| 37 | + | } | |
| 34 | 38 | } | |
| 35 | 39 | } | |
| 36 | 40 | ||
| @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.safeDrawingPadding | |||
| 20 | 20 | import androidx.compose.foundation.layout.size | |
| 21 | 21 | import androidx.compose.foundation.pager.HorizontalPager | |
| 22 | 22 | import androidx.compose.foundation.pager.rememberPagerState | |
| 23 | + | import androidx.compose.foundation.relocation.BringIntoViewRequester | |
| 24 | + | import androidx.compose.foundation.relocation.bringIntoViewRequester | |
| 23 | 25 | import androidx.compose.foundation.rememberScrollState | |
| 24 | 26 | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 25 | 27 | import androidx.compose.foundation.text.BasicText | |
| @@ -49,6 +51,7 @@ import androidx.compose.ui.unit.dp | |||
| 49 | 51 | import androidx.compose.ui.unit.sp | |
| 50 | 52 | import androidx.compose.ui.window.Dialog | |
| 51 | 53 | import kotlin.math.abs | |
| 54 | + | import kotlinx.coroutines.delay | |
| 52 | 55 | import kotlinx.coroutines.launch | |
| 53 | 56 | ||
| 54 | 57 | /** | |
| @@ -104,6 +107,8 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 104 | 107 | when (pages[index]) { | |
| 105 | 108 | Page.Overview -> Overview(app, askPermission) { open(pages.indexOf(it)) } | |
| 106 | 109 | Page.Equalizer -> Equalizer(app) | |
| 110 | + | Page.Gestures -> Gestures(app) | |
| 111 | + | Page.Earbuds -> Earbuds(app) | |
| 107 | 112 | Page.App -> AppPage(app) | |
| 108 | 113 | } | |
| 109 | 114 | Spacer(Modifier.size(12.dp)) | |
| @@ -128,6 +133,15 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 128 | 133 | ) | |
| 129 | 134 | } | |
| 130 | 135 | app.prompt?.let { NameDialog(app, it) } | |
| 136 | + | if (app.confirmFind) { | |
| 137 | + | // The sound is loud enough to hurt in the ears. | |
| 138 | + | Confirm( | |
| 139 | + | "Ring your buds?", | |
| 140 | + | "Take them out of your ears first: the sound is loud. They ring until you press Stop.", | |
| 141 | + | "Ring", | |
| 142 | + | app::answerFind, | |
| 143 | + | ) | |
| 144 | + | } | |
| 131 | 145 | } | |
| 132 | 146 | } | |
| 133 | 147 | ||
| @@ -175,8 +189,17 @@ private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) { | |||
| 175 | 189 | Motion.eased(Motion.PAGE, look.motion), | |
| 176 | 190 | label = "word", | |
| 177 | 191 | ) | |
| 192 | + | // The open word scrolls into view once it has grown. | |
| 193 | + | val requester = remember { BringIntoViewRequester() } | |
| 194 | + | LaunchedEffect(isOpen) { | |
| 195 | + | if (isOpen) { | |
| 196 | + | if (look.motion) delay(Motion.PAGE.toLong()) | |
| 197 | + | requester.bringIntoView() | |
| 198 | + | } | |
| 199 | + | } | |
| 178 | 200 | Row( | |
| 179 | 201 | Modifier | |
| 202 | + | .bringIntoViewRequester(requester) | |
| 180 | 203 | .clip(RoundedCornerShape(20.dp)) | |
| 181 | 204 | .clickable(remember { MutableInteractionSource() }, indication = null) { onOpen(index) } | |
| 182 | 205 | .padding(horizontal = 8.dp, vertical = 4.dp), | |
| @@ -145,7 +145,8 @@ Written from Android's API reference. No other app's code is read for it (rule 2 | |||
| 145 | 145 | button. | |
| 146 | 146 | - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with | |
| 147 | 147 | `--ez demo true`. | |
| 148 | − | - Built so far: the look, the Overview, the Equalizer and the APP page. Next: Gestures, Earbuds. | |
| 148 | + | - Built: the look and every page of the desktop app (Overview, Equalizer, Gestures, Earbud | |
| 149 | + | settings), plus the APP page. | |
| 149 | 150 | *Done when* **A3** passes for every page. | |
| 150 | 151 | 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and | |
| 151 | 152 | tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key | |