Krolyxon
Commit 62ea4cb

Branches

Refs →

budctl62ea4cb

android: the Equalizer page

Step 5 of docs/android.md, the second page, after equalizer.rs.

- The presets, built-in and custom; the one in use has its circle
  filled. Touching one puts it in use; a custom one carries its pencil
  (open its bands) and its bin (delete it, after "Delete <name>?").
- A custom preset's bands: one vertical dotted slider each, the gains in
  dot-matrix figures. Lifting the finger saves the preset, which also
  puts it in use; bands moved while a save is on its way go after it,
  so the latest wins.
- New preset and Rename ask for a name, with the desktop's checks (not
  empty, no leading or trailing space, no control characters, not taken).
  A new preset opens its bands.
- The Overview gets the desktop's last card: a row that opens the
  Equalizer, with the preset in use.
- The dotted slider runs bottom to top too, and a round icon button
  joins the building blocks.

Checked on the emulator with the demo buds: choosing, dragging a band
(the page does not scroll with it), creating, deleting.
Author
krolyxon
Date
2026-11-01 23:03 +0530
Parent
0155f71
Commit
62ea4cb6ed8bc5b2c97de266c45216a9e1879a99

+450 −36

8 files changed

  1. M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +176 −0
  2. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +25 −3
  3. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt +47 −29
  4. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Equalizer.kt +116 −0
  5. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +10 −1
  6. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +68 −2
  7. M android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt +7 −0
  8. M docs/android.md +1 −1
M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt View
@@ -5,11 +5,13 @@ import androidx.compose.runtime.getValue
55 import androidx.compose.runtime.mutableStateListOf
66 import androidx.compose.runtime.mutableStateOf
77 import androidx.compose.runtime.setValue
8+import org.json.JSONArray
89 import org.json.JSONObject
910
1011 /** The pages, in order: the dot-matrix word and the caption above the page. */
1112 enum class Page(val word: String, val caption: String) {
1213 Overview("overview", "overview · battery, noise control, sound"),
14+ Equalizer("eq", "equalizer · presets, and the bands of yours"),
1315 App("app", "app · colors, motion, debug log"),
1416 }
1517
@@ -22,8 +24,19 @@ sealed interface Item {
2224 data class Switch(val key: String) : Item
2325
2426 data object Bass : Item
27+
28+ /** A preset chosen, saved, renamed or deleted. */
29+ data class Preset(val id: Int) : Item
30+
31+ data object NewPreset : Item
2532 }
2633
34+/** A custom preset's bands as moved on the screen, not saved on the buds yet. */
35+data class Draft(val preset: Int, val bands: List<EqBand>)
36+
37+/** The name asked for a new preset, or for renaming the preset [renaming]. */
38+data class NamePrompt(val renaming: Int?, val text: String, val problem: String? = null)
39+
2740 /** Where the app stands with the buds. */
2841 sealed interface Connection {
2942 data object Demo : Connection
@@ -71,6 +84,20 @@ class App(
7184 var confirmDual by mutableStateOf(false)
7285 private set
7386
87+ /** The custom preset whose bands are open. */
88+ var editing by mutableStateOf<Int?>(null)
89+ private set
90+
91+ var draft by mutableStateOf<Draft?>(null)
92+ private set
93+
94+ /** "Delete <preset>?" is open, for this preset. */
95+ var confirmDelete by mutableStateOf<Int?>(null)
96+ private set
97+
98+ var prompt by mutableStateOf<NamePrompt?>(null)
99+ private set
100+
74101 /** The commands sent and not answered yet, oldest first: each gets one event, in order. */
75102 private val inFlight = mutableStateListOf<Item>()
76103
@@ -121,6 +148,98 @@ class App(
121148 command(Item.Bass, JSONObject().put("command", "bass").put("level", level))
122149 }
123150
151+ fun selectPreset(id: Int) {
152+ val preset = buds.preset(id) ?: return
153+ if (buds.eq == Buds.key(preset)) return
154+ val json = if (preset.custom) customEq(preset, emptyList()) else JSONObject().put("command", "eq").put("preset", preset.name)
155+ command(Item.Preset(id), json)
156+ }
157+
158+ /** Opens a custom preset's bands, or closes them. */
159+ fun editPreset(id: Int) {
160+ editing = if (editing == id) null else id
161+ }
162+
163+ fun doneEditing() {
164+ editing = null
165+ }
166+
167+ /** Moves a band on the screen, within the preset's range. Nothing goes to the buds yet. */
168+ fun moveBand(id: Int, hz: Int, gain: Int) {
169+ val preset = buds.preset(id) ?: return
170+ val range = preset.range ?: Int.MIN_VALUE..Int.MAX_VALUE
171+ val bands = draft?.takeIf { it.preset == id }?.bands ?: preset.bands
172+ draft = Draft(id, bands.map { if (it.hz == hz) it.copy(gain = gain.coerceIn(range)) else it })
173+ }
174+
175+ /** Saves the moved bands. If a write to the preset is on its way, they go after it. */
176+ fun saveBands(id: Int) {
177+ val bands = draft?.takeIf { it.preset == id }?.bands ?: return
178+ val preset = buds.preset(id) ?: return
179+ if (Item.Preset(id) !in inFlight) command(Item.Preset(id), customEq(preset, bands))
180+ }
181+
182+ fun deletePreset(id: Int) {
183+ confirmDelete = id
184+ }
185+
186+ fun answerDelete(yes: Boolean) {
187+ val id = confirmDelete
188+ confirmDelete = null
189+ val preset = id?.let(buds::preset) ?: return
190+ if (!yes) return
191+ if (editing == id) editing = null
192+ command(Item.Preset(preset.id), JSONObject().put("command", "delete-eq").put("preset", Buds.key(preset)))
193+ }
194+
195+ fun newPreset() {
196+ prompt = NamePrompt(null, "")
197+ }
198+
199+ fun renamePreset(id: Int) {
200+ prompt = NamePrompt(id, buds.preset(id)?.name ?: "")
201+ }
202+
203+ fun typeName(text: String) {
204+ prompt = prompt?.copy(text = text, problem = null)
205+ }
206+
207+ fun cancelName() {
208+ prompt = null
209+ }
210+
211+ /** Creates or renames the preset, if the name will do. What the buds refuse comes back as an error. */
212+ fun submitName() {
213+ val asked = prompt ?: return
214+ val text = asked.text
215+ val taken = buds.presets?.presets.orEmpty().any { it.custom && it.name == text && it.id != asked.renaming }
216+ val problem = when {
217+ text.isEmpty() || text.trim() != text -> "A name cannot be empty, or start or end with a space."
218+ text.any(Char::isISOControl) -> "A name cannot hold control characters."
219+ taken -> "A preset is called $text already."
220+ else -> null
221+ }
222+ if (problem != null) {
223+ prompt = asked.copy(problem = problem)
224+ return
225+ }
226+ prompt = null
227+ val id = asked.renaming
228+ if (id == null) {
229+ command(Item.NewPreset, JSONObject().put("command", "create-eq").put("name", text))
230+ } else {
231+ val preset = buds.preset(id) ?: return
232+ if (preset.name != text) {
233+ command(Item.Preset(id), JSONObject().put("command", "rename-eq").put("preset", Buds.key(preset)).put("name", text))
234+ }
235+ }
236+ }
237+
238+ private fun customEq(preset: EqPreset, bands: List<EqBand>) = JSONObject()
239+ .put("command", "custom-eq")
240+ .put("preset", Buds.key(preset))
241+ .put("bands", JSONArray(bands.map { JSONObject().put("hz", it.hz).put("gain", it.gain) }))
242+
124243 private fun command(item: Item, json: JSONObject) {
125244 if (item != Item.Read && item in inFlight) return
126245 if (demo) return pretend(item, json)
@@ -158,6 +277,25 @@ class App(
158277 buds = buds.copy(switches = buds.switches.map { if (it.key == now.key) now else it })
159278 connection = Connection.Live
160279 }
280+ "eq" -> {
281+ buds = buds.withEq(event.getString("preset"))
282+ connection = Connection.Live
283+ }
284+ "custom-eq" -> {
285+ val saved = parsePreset(event.getJSONObject("preset"))
286+ buds = buds.withCustom(saved)
287+ connection = Connection.Live
288+ // Bands moved while this write was on its way go out now.
289+ draft?.takeIf { it.preset == saved.id }?.let {
290+ if (it.bands == saved.bands) draft = null else saveBands(saved.id)
291+ }
292+ }
293+ "presets" -> {
294+ buds = buds.withPresets(parsePresets(event.getJSONObject("eq")))
295+ connection = Connection.Live
296+ // A new preset opens its bands: it is the one in use.
297+ if (item == Item.NewPreset) editing = buds.inUse()?.id
298+ }
161299 "bass" -> {
162300 val level = event.getInt("level")
163301 buds = buds.copy(bass = level)
@@ -169,6 +307,7 @@ class App(
169307 connection = Connection.Failed(kind, event.getString("message"))
170308 if (kind == "not-found") buds = buds.copy(connected = false)
171309 if (item == Item.Bass) bassDraft = null
310+ if (item is Item.Preset && draft?.preset == item.id) draft = null
172311 }
173312 }
174313 }
@@ -193,6 +332,40 @@ class App(
193332 buds = buds.copy(bass = json.getInt("level"))
194333 bassDraft = null
195334 }
335+ is Item.Preset, Item.NewPreset -> pretendEq(json)
336+ }
337+ }
338+
339+ /** The demo buds' presets, changed as the real ones would be. */
340+ private fun pretendEq(json: JSONObject) {
341+ val presets = buds.presets?.presets.orEmpty()
342+ when (json.getString("command")) {
343+ "eq" -> buds = buds.withEq(json.getString("preset"))
344+ "custom-eq" -> {
345+ val key = json.getString("preset")
346+ val preset = presets.find { Buds.key(it) == key } ?: return
347+ val bands = json.getJSONArray("bands").let { a -> List(a.length()) { a.getJSONObject(it) } }
348+ .associate { it.getInt("hz") to it.getInt("gain") }
349+ buds = buds.withCustom(preset.copy(bands = preset.bands.map { it.copy(gain = bands[it.hz] ?: it.gain) }))
350+ draft = null
351+ }
352+ "create-eq" -> {
353+ val id = (presets.maxOfOrNull { it.id } ?: 3) + 1
354+ val flat = (presets.firstOrNull { it.custom }?.bands ?: DEMO_BANDS).map { it.copy(gain = 0) }
355+ buds = buds.withCustom(EqPreset(json.getString("name"), id, true, flat, -6..6))
356+ editing = id
357+ }
358+ "rename-eq" -> {
359+ val key = json.getString("preset")
360+ val preset = presets.find { Buds.key(it) == key } ?: return
361+ buds = buds.withCustom(preset.copy(name = json.getString("name")))
362+ }
363+ "delete-eq" -> {
364+ val key = json.getString("preset")
365+ val left = presets.filterNot { Buds.key(it) == key }
366+ val current = buds.eq.takeUnless { it == key } ?: left.first().name
367+ buds = buds.withPresets(EqPresets(current, left))
368+ }
196369 }
197370 }
198371
@@ -223,5 +396,8 @@ class App(
223396 const val MATERIAL_YOU = "material_you"
224397 const val MOTION = "motion"
225398 const val MAX_LINES = 1_000
399+
400+ /** The bands a demo preset starts with when there is no custom one to copy. */
401+ val DEMO_BANDS = listOf(50, 250, 1000, 4000, 8000, 16000).map { EqBand(it, 0) }
226402 }
227403 }
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt View
@@ -70,6 +70,26 @@ data class Buds(
7070 return shownName(preset)
7171 }
7272
73+ /** The preset with this id. */
74+ fun preset(id: Int) = presets?.presets?.find { it.id == id }
75+
76+ /** The custom preset in use, if one is. */
77+ fun inUse() = presets?.presets?.find { it.custom && key(it) == eq }
78+
79+ /** [now] is the preset in use. */
80+ fun withEq(now: String) = copy(eq = now, presets = presets?.copy(current = now))
81+
82+ /** A custom preset as the buds report it after a write: saved, and in use. A new one joins the list. */
83+ fun withCustom(saved: EqPreset): Buds {
84+ val list = presets?.presets.orEmpty()
85+ val known = list.any { it.custom && it.id == saved.id }
86+ val updated = if (known) list.map { if (it.custom && it.id == saved.id) saved else it } else list + saved
87+ return copy(presets = EqPresets(key(saved), updated), eq = key(saved))
88+ }
89+
90+ /** The whole preset list, as the buds report it after a create or a delete. */
91+ fun withPresets(all: EqPresets) = copy(presets = all, eq = all.current)
92+
7393 /** The buds as a reading says, keeping what the app knows and the buds do not report. */
7494 fun read(reading: JSONObject): Buds {
7595 val status = reading.getJSONObject("status")
@@ -84,7 +104,7 @@ data class Buds(
84104 switches = status.optJSONArray("switches").objects().map(::parseSwitch),
85105 codec = status.text("codec"),
86106 firmware = status.text("firmware"),
87− presets = reading.optJSONObject("eq")?.let(::presets),
107+ presets = reading.optJSONObject("eq")?.let(::parsePresets),
88108 touch = reading.optJSONObject("touch")?.let(::touch),
89109 bassRange = reading.optJSONArray("bass_range")?.let { it.getInt(0)..it.getInt(1) },
90110 dual = reading.optJSONArray("dual")?.objects()?.map(::dual),
@@ -177,7 +197,8 @@ internal fun parseNoise(json: JSONObject) = NoiseControl(json.getString("mode"),
177197 /** A switch, as a reading and the `switch` event give it. */
178198 internal fun parseSwitch(json: JSONObject) = Switch(json.getString("key"), json.getString("label"), json.getBoolean("on"))
179199
180−private fun preset(json: JSONObject) = EqPreset(
200+/** An EQ preset, as a reading and the `custom-eq` event give it. */
201+internal fun parsePreset(json: JSONObject) = EqPreset(
181202 json.getString("name"),
182203 json.getInt("id"),
183204 json.getBoolean("custom"),
@@ -185,7 +206,8 @@ private fun preset(json: JSONObject) = EqPreset(
185206 json.optJSONArray("range")?.let { it.getInt(0)..it.getInt(1) },
186207 )
187208
188−private fun presets(json: JSONObject) = EqPresets(json.text("current"), json.optJSONArray("presets").objects().map(::preset))
209+/** The preset list, as a reading and the `presets` event give it. */
210+internal fun parsePresets(json: JSONObject) = EqPresets(json.text("current"), json.optJSONArray("presets").objects().map(::parsePreset))
189211
190212 private fun touch(json: JSONObject) = TouchControls(
191213 gestures = json.optJSONArray("gestures").objects().map {
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt View
@@ -11,12 +11,14 @@ import androidx.compose.animation.core.tween
1111 import androidx.compose.foundation.Canvas
1212 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1313 import androidx.compose.foundation.gestures.detectTapGestures
14+import androidx.compose.foundation.gestures.detectVerticalDragGestures
1415 import androidx.compose.foundation.layout.Box
1516 import androidx.compose.foundation.layout.BoxWithConstraints
1617 import androidx.compose.foundation.layout.aspectRatio
1718 import androidx.compose.foundation.layout.fillMaxWidth
1819 import androidx.compose.foundation.layout.height
1920 import androidx.compose.foundation.layout.size
21+import androidx.compose.foundation.layout.width
2022 import androidx.compose.foundation.text.BasicText
2123 import androidx.compose.runtime.Composable
2224 import androidx.compose.runtime.LaunchedEffect
@@ -32,9 +34,12 @@ import androidx.compose.ui.geometry.Offset
3234 import androidx.compose.ui.geometry.Size
3335 import androidx.compose.ui.graphics.Color
3436 import androidx.compose.ui.graphics.lerp
37+import androidx.compose.ui.input.pointer.PointerInputChange
3538 import androidx.compose.ui.input.pointer.pointerInput
3639 import androidx.compose.ui.text.TextStyle
40+import androidx.compose.ui.unit.Density
3741 import androidx.compose.ui.unit.Dp
42+import androidx.compose.ui.unit.IntSize
3843 import androidx.compose.ui.unit.dp
3944 import androidx.compose.ui.unit.sp
4045 import androidx.compose.ui.util.lerp
@@ -114,12 +119,20 @@ private val DOT = 5.dp
114119 private val DOT_BIG = 11.dp
115120
116121 /**
117− * A dotted slider over [low]..[high], one dot per step, left to right: the dots from 0 to the
118− * value are lit, and the value's own dot is larger. A touch or a drag calls [change]; lifting the
119− * finger calls [release], which is when a front end writes.
122+ * A dotted slider over [low]..[high], one dot per step, left to right or, [vertical], bottom to
123+ * top: the dots from 0 to the value are lit, and the value's own dot is larger. A touch or a drag
124+ * calls [change]; lifting the finger calls [release], which is when a front end writes.
120125 */
121126 @Composable
122−fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: () -> Unit, modifier: Modifier = Modifier) {
127+fun DotSlider(
128+ low: Int,
129+ high: Int,
130+ value: Int,
131+ change: (Int) -> Unit,
132+ release: () -> Unit,
133+ modifier: Modifier = Modifier,
134+ vertical: Boolean = false,
135+) {
123136 val look = LocalLook.current
124137 val p = look.p
125138 val onChange by rememberUpdatedState(change)
@@ -137,30 +150,31 @@ fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: (
137150 }
138151 }
139152 val steps = max(1, high - low)
153+ val sized = if (vertical) modifier.width(DOT_BIG + 22.dp).height(190.dp) else modifier.fillMaxWidth().height(DOT_BIG + 22.dp)
154+
155+ /** 0 to 1 along the slider for a point on it. */
156+ fun Density.along(at: Offset, size: IntSize): Float {
157+ val pad = DOT_BIG.toPx()
158+ val along = if (vertical) (size.height - pad - at.y) / max(1f, size.height - 2 * pad) else (at.x - pad) / max(1f, size.width - 2 * pad)
159+ return along.coerceIn(0f, 1f)
160+ }
140161 Canvas(
141− modifier
142− .fillMaxWidth()
143− .height(DOT_BIG + 22.dp)
144− .pointerInput(low, high) {
145− fun at(x: Float): Int {
146− val pad = DOT_BIG.toPx()
147− val along = ((x - pad) / max(1f, size.width - 2 * pad)).coerceIn(0f, 1f)
148− return (low + (along * steps).roundToInt()).coerceIn(low, high)
149− }
150− detectTapGestures { offset ->
151− onChange(at(offset.x))
162+ sized
163+ .pointerInput(low, high, vertical) {
164+ detectTapGestures { at ->
165+ onChange(low + (along(at, size) * steps).roundToInt())
152166 onRelease()
153167 }
154168 }
155− .pointerInput(low, high) {
156− fun at(x: Float): Int {
157− val pad = DOT_BIG.toPx()
158− val along = ((x - pad) / max(1f, size.width - 2 * pad)).coerceIn(0f, 1f)
159− return (low + (along * steps).roundToInt()).coerceIn(low, high)
160− }
161− detectHorizontalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ ->
169+ .pointerInput(low, high, vertical) {
170+ val drag = { change: PointerInputChange ->
162171 change.consume()
163− onChange(at(change.position.x))
172+ onChange(low + (along(change.position, size) * steps).roundToInt())
173+ }
174+ if (vertical) {
175+ detectVerticalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) }
176+ } else {
177+ detectHorizontalDragGestures(onDragEnd = { onRelease() }, onDragCancel = { onRelease() }) { change, _ -> drag(change) }
164178 }
165179 },
166180 ) {
@@ -170,14 +184,18 @@ fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: (
170184 val to = maxOf(base, value)
171185 val g = grow.value
172186 for (v in low..high) {
173− val x = pad + (v - low).toFloat() / steps * (size.width - 2 * pad)
174− val lit = v in from..to
175− val diameter = when {
176− v == value -> lerp(DOT.toPx(), DOT_BIG.toPx(), g)
177− v == left -> lerp(DOT_BIG.toPx(), DOT.toPx(), g)
187+ val along = (v - low).toFloat() / steps
188+ val at = if (vertical) {
189+ Offset(size.width / 2, size.height - pad - along * (size.height - 2 * pad))
190+ } else {
191+ Offset(pad + along * (size.width - 2 * pad), size.height / 2)
192+ }
193+ val diameter = when (v) {
194+ value -> lerp(DOT.toPx(), DOT_BIG.toPx(), g)
195+ left -> lerp(DOT_BIG.toPx(), DOT.toPx(), g)
178196 else -> DOT.toPx()
179197 }
180− drawCircle(if (lit || v == value) p.fill else p.s3, diameter / 2, Offset(x, size.height / 2))
198+ drawCircle(if (v in from..to) p.fill else p.s3, diameter / 2, at)
181199 }
182200 }
183201 }
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Equalizer.kt View
@@ -0,0 +1,116 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.shape.RoundedCornerShape
10+import androidx.compose.foundation.text.BasicText
11+import androidx.compose.runtime.Composable
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.draw.clip
15+import androidx.compose.ui.text.TextStyle
16+import androidx.compose.ui.text.font.FontWeight
17+import androidx.compose.ui.unit.dp
18+import androidx.compose.ui.unit.sp
19+
20+/** The Equalizer page (crates/budctl-gui/src/equalizer.rs): the presets, and the bands of the custom ones. */
21+@Composable
22+fun Equalizer(app: App) {
23+ val p = LocalLook.current.p
24+ val buds = app.buds
25+ val presets = buds.presets
26+ if (presets == null) {
27+ Note("These buds did not list their presets.")
28+ return
29+ }
30+ Card {
31+ for (preset in presets.presets) {
32+ val id = preset.id
33+ val chosen = buds.eq == Buds.key(preset)
34+ val open = preset.custom && app.editing == id
35+ val subtitle = when {
36+ app.saving(Item.Preset(id)) -> SAVING
37+ chosen && open -> "In use, open below"
38+ chosen -> "In use"
39+ open -> "Your preset, open below"
40+ preset.custom -> "Your preset"
41+ else -> "Built in"
42+ }
43+ SettingRow(Icon.Equalizer, Buds.shownName(preset), subtitle, current = chosen, onClick = { app.selectPreset(id) }) {
44+ if (preset.custom) {
45+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
46+ IconButton(Icon.Pencil) { app.editPreset(id) }
47+ IconButton(Icon.Trash) { app.deletePreset(id) }
48+ }
49+ }
50+ }
51+ }
52+ }
53+ Row(verticalAlignment = Alignment.CenterVertically) {
54+ Note("A new preset starts flat and is put to use.", Modifier.weight(1f))
55+ if (app.saving(Item.NewPreset)) Label("Creating…", color = p.live, modifier = Modifier.padding(end = 10.dp))
56+ Pill("New preset", onClick = app::newPreset)
57+ }
58+ val edited = app.editing?.let(buds::preset)?.takeIf { it.custom }
59+ when {
60+ edited != null -> Bands(app, edited)
61+ presets.presets.any { it.custom } -> Note("Use the pencil on one of your presets to change its bands.")
62+ }
63+}
64+
65+/** A custom preset's bands, one dotted slider each. */
66+@Composable
67+private fun Bands(app: App, preset: EqPreset) {
68+ val look = LocalLook.current
69+ val p = look.p
70+ val id = preset.id
71+ val gains = app.draft?.takeIf { it.preset == id }?.bands ?: preset.bands
72+ val range = preset.range ?: -6..6
73+ Column(
74+ Modifier
75+ .fillMaxWidth()
76+ .clip(RoundedCornerShape(28.dp))
77+ .background(p.s1)
78+ .padding(20.dp),
79+ verticalArrangement = Arrangement.spacedBy(16.dp),
80+ ) {
81+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
82+ Words(preset.name, 20.sp, weight = FontWeight.SemiBold)
83+ Label("%+d to %+d dB".format(range.first, range.last), modifier = Modifier.weight(1f))
84+ if (app.saving(Item.Preset(id))) Label(SAVING, color = p.live)
85+ }
86+ Words("Moving a band saves this preset and puts it in use, so you hear it.", 13.5.sp, p.fgDim)
87+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
88+ Pill("Rename") { app.renamePreset(id) }
89+ Pill("Done", onClick = app::doneEditing)
90+ }
91+ Row(Modifier.fillMaxWidth()) {
92+ for (band in gains) {
93+ Column(
94+ Modifier.weight(1f),
95+ horizontalAlignment = Alignment.CenterHorizontally,
96+ verticalArrangement = Arrangement.spacedBy(10.dp),
97+ ) {
98+ // Doto is never set under 26 sp.
99+ BasicText(gain(band.gain), style = TextStyle(fontFamily = look.fonts.dot, fontSize = 26.sp, color = p.fg))
100+ DotSlider(
101+ range.first,
102+ range.last,
103+ band.gain,
104+ change = { app.moveBand(id, band.hz, it) },
105+ release = { app.saveBands(id) },
106+ vertical = true,
107+ )
108+ Label(frequency(band.hz), 10.sp)
109+ }
110+ }
111+ }
112+ }
113+}
114+
115+/** `50` → "50 Hz", `16000` → "16 kHz". */
116+private fun frequency(hz: Int) = if (hz >= 1000 && hz % 1000 == 0) "${hz / 1000} kHz" else "$hz Hz"
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt View
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
1313
1414 /** The Overview page (crates/budctl-gui/src/overview.rs): battery, noise control, the switches. */
1515 @Composable
16−fun Overview(app: App, askPermission: () -> Unit) {
16+fun Overview(app: App, askPermission: () -> Unit, open: (Page) -> Unit) {
1717 if (!app.permitted) {
1818 Note("budctl needs the Nearby devices permission to reach the buds.")
1919 Row(Modifier.padding(horizontal = 18.dp)) { Pill("Allow", filled = true, onClick = askPermission) }
@@ -23,6 +23,15 @@ fun Overview(app: App, askPermission: () -> Unit) {
2323 Battery(buds)
2424 buds.noise?.let { NoiseChoice(app, it) }
2525 if (buds.switches.isNotEmpty()) Switches(app)
26+ More(app, open)
27+}
28+
29+/** The rows that open the other pages. */
30+@Composable
31+private fun More(app: App, open: (Page) -> Unit) {
32+ Card {
33+ SettingRow(Icon.Equalizer, "Equalizer", app.buds.eqName(), onClick = { open(Page.Equalizer) }) { Chevron() }
34+ }
2635 }
2736
2837 @Composable
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt View
@@ -4,6 +4,7 @@ import androidx.compose.animation.animateColorAsState
44 import androidx.compose.animation.core.animateFloatAsState
55 import androidx.compose.animation.core.tween
66 import androidx.compose.foundation.background
7+import androidx.compose.foundation.border
78 import androidx.compose.foundation.clickable
89 import androidx.compose.foundation.horizontalScroll
910 import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -22,19 +23,28 @@ import androidx.compose.foundation.pager.rememberPagerState
2223 import androidx.compose.foundation.rememberScrollState
2324 import androidx.compose.foundation.shape.RoundedCornerShape
2425 import androidx.compose.foundation.text.BasicText
26+import androidx.compose.foundation.text.BasicTextField
27+import androidx.compose.foundation.text.KeyboardActions
28+import androidx.compose.foundation.text.KeyboardOptions
2529 import androidx.compose.foundation.verticalScroll
2630 import androidx.compose.runtime.Composable
2731 import androidx.compose.runtime.CompositionLocalProvider
32+import androidx.compose.runtime.LaunchedEffect
2833 import androidx.compose.runtime.getValue
2934 import androidx.compose.runtime.remember
3035 import androidx.compose.runtime.rememberCoroutineScope
3136 import androidx.compose.ui.Alignment
3237 import androidx.compose.ui.Modifier
3338 import androidx.compose.ui.draw.clip
39+import androidx.compose.ui.focus.FocusRequester
40+import androidx.compose.ui.focus.focusRequester
41+import androidx.compose.ui.graphics.SolidColor
3442 import androidx.compose.ui.graphics.graphicsLayer
3543 import androidx.compose.ui.platform.LocalContext
3644 import androidx.compose.ui.text.TextStyle
3745 import androidx.compose.ui.text.font.FontWeight
46+import androidx.compose.ui.text.input.ImeAction
47+import androidx.compose.ui.text.input.KeyboardCapitalization
3848 import androidx.compose.ui.unit.dp
3949 import androidx.compose.ui.unit.sp
4050 import androidx.compose.ui.window.Dialog
@@ -63,12 +73,14 @@ fun Screen(app: App, askPermission: () -> Unit) {
6373 .safeDrawingPadding(),
6474 ) {
6575 Header(app)
66− PageWords(pages, pager.currentPage) { page ->
76+ val open = { page: Int ->
6777 scope.launch {
6878 if (look.motion) pager.animateScrollToPage(page, animationSpec = tween(Motion.PAGE, easing = Motion.EASE))
6979 else pager.scrollToPage(page)
7080 }
81+ Unit
7182 }
83+ PageWords(pages, pager.currentPage, open)
7284 HorizontalPager(pager, Modifier.weight(1f)) { index ->
7385 Box(
7486 Modifier
@@ -90,7 +102,8 @@ fun Screen(app: App, askPermission: () -> Unit) {
90102 Caption(pages[index].caption)
91103 app.problem?.let { Problem(it) { app.read() } }
92104 when (pages[index]) {
93− Page.Overview -> Overview(app, askPermission)
105+ Page.Overview -> Overview(app, askPermission) { open(pages.indexOf(it)) }
106+ Page.Equalizer -> Equalizer(app)
94107 Page.App -> AppPage(app)
95108 }
96109 Spacer(Modifier.size(12.dp))
@@ -106,6 +119,15 @@ fun Screen(app: App, askPermission: () -> Unit) {
106119 app::answerDual,
107120 )
108121 }
122+ app.confirmDelete?.let { id ->
123+ Confirm(
124+ "Delete ${app.buds.preset(id)?.name ?: "this preset"}?",
125+ "Its bands are lost. The buds keep their other presets.",
126+ "Delete",
127+ app::answerDelete,
128+ )
129+ }
130+ app.prompt?.let { NameDialog(app, it) }
109131 }
110132 }
111133
@@ -169,6 +191,50 @@ private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) {
169191 }
170192 }
171193
194+/** Asks for a preset's name: a new one's, or a new name for one. */
195+@Composable
196+private fun NameDialog(app: App, asked: NamePrompt) {
197+ val look = LocalLook.current
198+ val p = look.p
199+ val heading = asked.renaming?.let { "Rename ${app.buds.preset(it)?.name ?: "the preset"}" } ?: "New preset"
200+ val focus = remember { FocusRequester() }
201+ Dialog(onDismissRequest = app::cancelName) {
202+ CompositionLocalProvider(LocalLook provides look) {
203+ Column(
204+ Modifier
205+ .clip(RoundedCornerShape(28.dp))
206+ .background(p.s1)
207+ .padding(24.dp),
208+ verticalArrangement = Arrangement.spacedBy(18.dp),
209+ ) {
210+ Words(heading, 22.sp, weight = FontWeight.SemiBold)
211+ BasicTextField(
212+ asked.text,
213+ app::typeName,
214+ Modifier
215+ .fillMaxWidth()
216+ .focusRequester(focus)
217+ .clip(RoundedCornerShape(percent = 50))
218+ .background(p.s2)
219+ .border(1.dp, p.fill, RoundedCornerShape(percent = 50))
220+ .padding(horizontal = 20.dp, vertical = 12.dp),
221+ textStyle = TextStyle(fontFamily = look.fonts.ui, fontSize = 16.sp, color = p.fg),
222+ keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done),
223+ keyboardActions = KeyboardActions(onDone = { app.submitName() }),
224+ singleLine = true,
225+ cursorBrush = SolidColor(p.fill),
226+ )
227+ asked.problem?.let { Words(it, 13.sp, p.err) }
228+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
229+ Pill("Cancel", onClick = app::cancelName)
230+ Pill(if (asked.renaming == null) "Create" else "Rename", filled = true, onClick = app::submitName)
231+ }
232+ }
233+ }
234+ }
235+ LaunchedEffect(Unit) { focus.requestFocus() }
236+}
237+
172238 /** A question over the dimmed page: Cancel, or [action]. */
173239 @Composable
174240 private fun Confirm(heading: String, words: String, action: String, answer: (Boolean) -> Unit) {
M android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt View
@@ -193,6 +193,13 @@ fun Pill(words: String, filled: Boolean = false, onClick: () -> Unit) {
193193 }
194194 }
195195
196+/** A round button with just an icon, like the pencil on a preset's row. */
197+@Composable
198+fun IconButton(icon: Icon, onClick: () -> Unit) {
199+ val p = LocalLook.current.p
200+ Box(pressable(p.s2, onClick, CircleShape).size(40.dp), contentAlignment = Alignment.Center) { Glyph(icon, 18.dp, p.fg) }
201+}
202+
196203 /** A value shown in a dark pill, like a firmware version or "NOT SET". */
197204 @Composable
198205 fun ValueChip(words: String) {
M docs/android.md View
@@ -145,7 +145,7 @@ 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 and the APP page. Next: Equalizer, Gestures, Earbuds.
148+ - Built so far: the look, the Overview, the Equalizer and the APP page. Next: Gestures, Earbuds.
149149 *Done when* **A3** passes for every page.
150150 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and
151151 tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key