Krolyxon
Commit a41a513

Branches

Refs →

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.
Author
krolyxon
Date
2026-11-01 23:12 +0530
Parent
ea6892e
Commit
a41a513921d4cc98dcb614f24b2eccafde46f37a

+382 −4

7 files changed

  1. M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +96 −0
  2. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +9 −3
  3. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Earbuds.kt +88 −0
  4. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Gestures.kt +160 −0
  5. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +4 −0
  6. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +23 −0
  7. M docs/android.md +2 −1
M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt View
@@ -12,6 +12,8 @@ import org.json.JSONObject
1212 enum class Page(val word: String, val caption: String) {
1313 Overview("overview", "overview · battery, noise control, sound"),
1414 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"),
1517 App("app", "app · colors, motion, debug log"),
1618 }
1719
@@ -29,8 +31,20 @@ sealed interface Item {
2931 data class Preset(val id: Int) : Item
3032
3133 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
3243 }
3344
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+
3448 /** A custom preset's bands as moved on the screen, not saved on the buds yet. */
3549 data class Draft(val preset: Int, val bands: List<EqBand>)
3650
@@ -98,6 +112,17 @@ class App(
98112 var prompt by mutableStateOf<NamePrompt?>(null)
99113 private set
100114
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+
101126 /** The commands sent and not answered yet, oldest first: each gets one event, in order. */
102127 private val inFlight = mutableStateListOf<Item>()
103128
@@ -235,6 +260,42 @@ class App(
235260 }
236261 }
237262
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+
238299 private fun customEq(preset: EqPreset, bands: List<EqBand>) = JSONObject()
239300 .put("command", "custom-eq")
240301 .put("preset", Buds.key(preset))
@@ -296,6 +357,22 @@ class App(
296357 // A new preset opens its bands: it is the one in use.
297358 if (item == Item.NewPreset) editing = buds.inUse()?.id
298359 }
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+ }
299376 "bass" -> {
300377 val level = event.getInt("level")
301378 buds = buds.copy(bass = level)
@@ -307,7 +384,9 @@ class App(
307384 connection = Connection.Failed(kind, event.getString("message"))
308385 if (kind == "not-found") buds = buds.copy(connected = false)
309386 if (item == Item.Bass) bassDraft = null
387+ // The buds keep what they had: show that, not what was picked.
310388 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
311390 }
312391 }
313392 }
@@ -333,6 +412,23 @@ class App(
333412 bassDraft = null
334413 }
335414 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")
336432 }
337433 }
338434
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt View
@@ -70,6 +70,10 @@ data class Buds(
7070 return shownName(preset)
7171 }
7272
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+
7377 /** The preset with this id. */
7478 fun preset(id: Int) = presets?.presets?.find { it.id == id }
7579
@@ -197,6 +201,10 @@ internal fun parseNoise(json: JSONObject) = NoiseControl(json.getString("mode"),
197201 /** A switch, as a reading and the `switch` event give it. */
198202 internal fun parseSwitch(json: JSONObject) = Switch(json.getString("key"), json.getString("label"), json.getBoolean("on"))
199203
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+
200208 /** An EQ preset, as a reading and the `custom-eq` event give it. */
201209 internal fun parsePreset(json: JSONObject) = EqPreset(
202210 json.getString("name"),
@@ -210,9 +218,7 @@ internal fun parsePreset(json: JSONObject) = EqPreset(
210218 internal fun parsePresets(json: JSONObject) = EqPresets(json.text("current"), json.optJSONArray("presets").objects().map(::parsePreset))
211219
212220 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),
216222 holdCycle = json.optJSONArray("hold_cycle")?.strings(),
217223 choices = json.optJSONArray("choices").objects().map { GestureChoice(it.getString("gesture"), it.optJSONArray("actions").strings()) },
218224 cycleModes = json.optJSONArray("cycle_modes").strings(),
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Earbuds.kt View
@@ -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(" · ")
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Gestures.kt View
@@ -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+}
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt View
@@ -31,6 +31,10 @@ fun Overview(app: App, askPermission: () -> Unit, open: (Page) -> Unit) {
3131 private fun More(app: App, open: (Page) -> Unit) {
3232 Card {
3333 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+ }
3438 }
3539 }
3640
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt View
@@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.safeDrawingPadding
2020 import androidx.compose.foundation.layout.size
2121 import androidx.compose.foundation.pager.HorizontalPager
2222 import androidx.compose.foundation.pager.rememberPagerState
23+import androidx.compose.foundation.relocation.BringIntoViewRequester
24+import androidx.compose.foundation.relocation.bringIntoViewRequester
2325 import androidx.compose.foundation.rememberScrollState
2426 import androidx.compose.foundation.shape.RoundedCornerShape
2527 import androidx.compose.foundation.text.BasicText
@@ -49,6 +51,7 @@ import androidx.compose.ui.unit.dp
4951 import androidx.compose.ui.unit.sp
5052 import androidx.compose.ui.window.Dialog
5153 import kotlin.math.abs
54+import kotlinx.coroutines.delay
5255 import kotlinx.coroutines.launch
5356
5457 /**
@@ -104,6 +107,8 @@ fun Screen(app: App, askPermission: () -> Unit) {
104107 when (pages[index]) {
105108 Page.Overview -> Overview(app, askPermission) { open(pages.indexOf(it)) }
106109 Page.Equalizer -> Equalizer(app)
110+ Page.Gestures -> Gestures(app)
111+ Page.Earbuds -> Earbuds(app)
107112 Page.App -> AppPage(app)
108113 }
109114 Spacer(Modifier.size(12.dp))
@@ -128,6 +133,15 @@ fun Screen(app: App, askPermission: () -> Unit) {
128133 )
129134 }
130135 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+ }
131145 }
132146 }
133147
@@ -175,8 +189,17 @@ private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) {
175189 Motion.eased(Motion.PAGE, look.motion),
176190 label = "word",
177191 )
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+ }
178200 Row(
179201 Modifier
202+ .bringIntoViewRequester(requester)
180203 .clip(RoundedCornerShape(20.dp))
181204 .clickable(remember { MutableInteractionSource() }, indication = null) { onOpen(index) }
182205 .padding(horizontal = 8.dp, vertical = 4.dp),
M docs/android.md View
@@ -145,7 +145,8 @@ Written from Android's API reference. No other app's code is read for it (rule 2
145145 button.
146146 - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with
147147 `--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.
149150 *Done when* **A3** passes for every page.
150151 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and
151152 tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key