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.
+450 −36
8 files changed
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +176 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +25 −3
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt +47 −29
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Equalizer.kt +116 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +10 −1
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +68 −2
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt +7 −0
- M docs/android.md +1 −1
| @@ -5,11 +5,13 @@ import androidx.compose.runtime.getValue | |||
| 5 | 5 | import androidx.compose.runtime.mutableStateListOf | |
| 6 | 6 | import androidx.compose.runtime.mutableStateOf | |
| 7 | 7 | import androidx.compose.runtime.setValue | |
| 8 | + | import org.json.JSONArray | |
| 8 | 9 | import org.json.JSONObject | |
| 9 | 10 | ||
| 10 | 11 | /** The pages, in order: the dot-matrix word and the caption above the page. */ | |
| 11 | 12 | enum class Page(val word: String, val caption: String) { | |
| 12 | 13 | Overview("overview", "overview · battery, noise control, sound"), | |
| 14 | + | Equalizer("eq", "equalizer · presets, and the bands of yours"), | |
| 13 | 15 | App("app", "app · colors, motion, debug log"), | |
| 14 | 16 | } | |
| 15 | 17 | ||
| @@ -22,8 +24,19 @@ sealed interface Item { | |||
| 22 | 24 | data class Switch(val key: String) : Item | |
| 23 | 25 | ||
| 24 | 26 | 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 | |
| 25 | 32 | } | |
| 26 | 33 | ||
| 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 | + | ||
| 27 | 40 | /** Where the app stands with the buds. */ | |
| 28 | 41 | sealed interface Connection { | |
| 29 | 42 | data object Demo : Connection | |
| @@ -71,6 +84,20 @@ class App( | |||
| 71 | 84 | var confirmDual by mutableStateOf(false) | |
| 72 | 85 | private set | |
| 73 | 86 | ||
| 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 | + | ||
| 74 | 101 | /** The commands sent and not answered yet, oldest first: each gets one event, in order. */ | |
| 75 | 102 | private val inFlight = mutableStateListOf<Item>() | |
| 76 | 103 | ||
| @@ -121,6 +148,98 @@ class App( | |||
| 121 | 148 | command(Item.Bass, JSONObject().put("command", "bass").put("level", level)) | |
| 122 | 149 | } | |
| 123 | 150 | ||
| 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 | + | ||
| 124 | 243 | private fun command(item: Item, json: JSONObject) { | |
| 125 | 244 | if (item != Item.Read && item in inFlight) return | |
| 126 | 245 | if (demo) return pretend(item, json) | |
| @@ -158,6 +277,25 @@ class App( | |||
| 158 | 277 | buds = buds.copy(switches = buds.switches.map { if (it.key == now.key) now else it }) | |
| 159 | 278 | connection = Connection.Live | |
| 160 | 279 | } | |
| 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 | + | } | |
| 161 | 299 | "bass" -> { | |
| 162 | 300 | val level = event.getInt("level") | |
| 163 | 301 | buds = buds.copy(bass = level) | |
| @@ -169,6 +307,7 @@ class App( | |||
| 169 | 307 | connection = Connection.Failed(kind, event.getString("message")) | |
| 170 | 308 | if (kind == "not-found") buds = buds.copy(connected = false) | |
| 171 | 309 | if (item == Item.Bass) bassDraft = null | |
| 310 | + | if (item is Item.Preset && draft?.preset == item.id) draft = null | |
| 172 | 311 | } | |
| 173 | 312 | } | |
| 174 | 313 | } | |
| @@ -193,6 +332,40 @@ class App( | |||
| 193 | 332 | buds = buds.copy(bass = json.getInt("level")) | |
| 194 | 333 | bassDraft = null | |
| 195 | 334 | } | |
| 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 | + | } | |
| 196 | 369 | } | |
| 197 | 370 | } | |
| 198 | 371 | ||
| @@ -223,5 +396,8 @@ class App( | |||
| 223 | 396 | const val MATERIAL_YOU = "material_you" | |
| 224 | 397 | const val MOTION = "motion" | |
| 225 | 398 | 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) } | |
| 226 | 402 | } | |
| 227 | 403 | } | |
| @@ -70,6 +70,26 @@ data class Buds( | |||
| 70 | 70 | return shownName(preset) | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 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 | + | ||
| 73 | 93 | /** The buds as a reading says, keeping what the app knows and the buds do not report. */ | |
| 74 | 94 | fun read(reading: JSONObject): Buds { | |
| 75 | 95 | val status = reading.getJSONObject("status") | |
| @@ -84,7 +104,7 @@ data class Buds( | |||
| 84 | 104 | switches = status.optJSONArray("switches").objects().map(::parseSwitch), | |
| 85 | 105 | codec = status.text("codec"), | |
| 86 | 106 | firmware = status.text("firmware"), | |
| 87 | − | presets = reading.optJSONObject("eq")?.let(::presets), | |
| 107 | + | presets = reading.optJSONObject("eq")?.let(::parsePresets), | |
| 88 | 108 | touch = reading.optJSONObject("touch")?.let(::touch), | |
| 89 | 109 | bassRange = reading.optJSONArray("bass_range")?.let { it.getInt(0)..it.getInt(1) }, | |
| 90 | 110 | dual = reading.optJSONArray("dual")?.objects()?.map(::dual), | |
| @@ -177,7 +197,8 @@ internal fun parseNoise(json: JSONObject) = NoiseControl(json.getString("mode"), | |||
| 177 | 197 | /** A switch, as a reading and the `switch` event give it. */ | |
| 178 | 198 | internal fun parseSwitch(json: JSONObject) = Switch(json.getString("key"), json.getString("label"), json.getBoolean("on")) | |
| 179 | 199 | ||
| 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( | |
| 181 | 202 | json.getString("name"), | |
| 182 | 203 | json.getInt("id"), | |
| 183 | 204 | json.getBoolean("custom"), | |
| @@ -185,7 +206,8 @@ private fun preset(json: JSONObject) = EqPreset( | |||
| 185 | 206 | json.optJSONArray("range")?.let { it.getInt(0)..it.getInt(1) }, | |
| 186 | 207 | ) | |
| 187 | 208 | ||
| 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)) | |
| 189 | 211 | ||
| 190 | 212 | private fun touch(json: JSONObject) = TouchControls( | |
| 191 | 213 | gestures = json.optJSONArray("gestures").objects().map { | |
| @@ -11,12 +11,14 @@ import androidx.compose.animation.core.tween | |||
| 11 | 11 | import androidx.compose.foundation.Canvas | |
| 12 | 12 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures | |
| 13 | 13 | import androidx.compose.foundation.gestures.detectTapGestures | |
| 14 | + | import androidx.compose.foundation.gestures.detectVerticalDragGestures | |
| 14 | 15 | import androidx.compose.foundation.layout.Box | |
| 15 | 16 | import androidx.compose.foundation.layout.BoxWithConstraints | |
| 16 | 17 | import androidx.compose.foundation.layout.aspectRatio | |
| 17 | 18 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 18 | 19 | import androidx.compose.foundation.layout.height | |
| 19 | 20 | import androidx.compose.foundation.layout.size | |
| 21 | + | import androidx.compose.foundation.layout.width | |
| 20 | 22 | import androidx.compose.foundation.text.BasicText | |
| 21 | 23 | import androidx.compose.runtime.Composable | |
| 22 | 24 | import androidx.compose.runtime.LaunchedEffect | |
| @@ -32,9 +34,12 @@ import androidx.compose.ui.geometry.Offset | |||
| 32 | 34 | import androidx.compose.ui.geometry.Size | |
| 33 | 35 | import androidx.compose.ui.graphics.Color | |
| 34 | 36 | import androidx.compose.ui.graphics.lerp | |
| 37 | + | import androidx.compose.ui.input.pointer.PointerInputChange | |
| 35 | 38 | import androidx.compose.ui.input.pointer.pointerInput | |
| 36 | 39 | import androidx.compose.ui.text.TextStyle | |
| 40 | + | import androidx.compose.ui.unit.Density | |
| 37 | 41 | import androidx.compose.ui.unit.Dp | |
| 42 | + | import androidx.compose.ui.unit.IntSize | |
| 38 | 43 | import androidx.compose.ui.unit.dp | |
| 39 | 44 | import androidx.compose.ui.unit.sp | |
| 40 | 45 | import androidx.compose.ui.util.lerp | |
| @@ -114,12 +119,20 @@ private val DOT = 5.dp | |||
| 114 | 119 | private val DOT_BIG = 11.dp | |
| 115 | 120 | ||
| 116 | 121 | /** | |
| 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. | |
| 120 | 125 | */ | |
| 121 | 126 | @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 | + | ) { | |
| 123 | 136 | val look = LocalLook.current | |
| 124 | 137 | val p = look.p | |
| 125 | 138 | val onChange by rememberUpdatedState(change) | |
| @@ -137,30 +150,31 @@ fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: ( | |||
| 137 | 150 | } | |
| 138 | 151 | } | |
| 139 | 152 | 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 | + | } | |
| 140 | 161 | 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()) | |
| 152 | 166 | onRelease() | |
| 153 | 167 | } | |
| 154 | 168 | } | |
| 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 -> | |
| 162 | 171 | 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) } | |
| 164 | 178 | } | |
| 165 | 179 | }, | |
| 166 | 180 | ) { | |
| @@ -170,14 +184,18 @@ fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: ( | |||
| 170 | 184 | val to = maxOf(base, value) | |
| 171 | 185 | val g = grow.value | |
| 172 | 186 | 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) | |
| 178 | 196 | else -> DOT.toPx() | |
| 179 | 197 | } | |
| 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) | |
| 181 | 199 | } | |
| 182 | 200 | } | |
| 183 | 201 | } | |
| @@ -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" | |
| @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp | |||
| 13 | 13 | ||
| 14 | 14 | /** The Overview page (crates/budctl-gui/src/overview.rs): battery, noise control, the switches. */ | |
| 15 | 15 | @Composable | |
| 16 | − | fun Overview(app: App, askPermission: () -> Unit) { | |
| 16 | + | fun Overview(app: App, askPermission: () -> Unit, open: (Page) -> Unit) { | |
| 17 | 17 | if (!app.permitted) { | |
| 18 | 18 | Note("budctl needs the Nearby devices permission to reach the buds.") | |
| 19 | 19 | Row(Modifier.padding(horizontal = 18.dp)) { Pill("Allow", filled = true, onClick = askPermission) } | |
| @@ -23,6 +23,15 @@ fun Overview(app: App, askPermission: () -> Unit) { | |||
| 23 | 23 | Battery(buds) | |
| 24 | 24 | buds.noise?.let { NoiseChoice(app, it) } | |
| 25 | 25 | 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 | + | } | |
| 26 | 35 | } | |
| 27 | 36 | ||
| 28 | 37 | @Composable | |
| @@ -4,6 +4,7 @@ import androidx.compose.animation.animateColorAsState | |||
| 4 | 4 | import androidx.compose.animation.core.animateFloatAsState | |
| 5 | 5 | import androidx.compose.animation.core.tween | |
| 6 | 6 | import androidx.compose.foundation.background | |
| 7 | + | import androidx.compose.foundation.border | |
| 7 | 8 | import androidx.compose.foundation.clickable | |
| 8 | 9 | import androidx.compose.foundation.horizontalScroll | |
| 9 | 10 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| @@ -22,19 +23,28 @@ import androidx.compose.foundation.pager.rememberPagerState | |||
| 22 | 23 | import androidx.compose.foundation.rememberScrollState | |
| 23 | 24 | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 24 | 25 | 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 | |
| 25 | 29 | import androidx.compose.foundation.verticalScroll | |
| 26 | 30 | import androidx.compose.runtime.Composable | |
| 27 | 31 | import androidx.compose.runtime.CompositionLocalProvider | |
| 32 | + | import androidx.compose.runtime.LaunchedEffect | |
| 28 | 33 | import androidx.compose.runtime.getValue | |
| 29 | 34 | import androidx.compose.runtime.remember | |
| 30 | 35 | import androidx.compose.runtime.rememberCoroutineScope | |
| 31 | 36 | import androidx.compose.ui.Alignment | |
| 32 | 37 | import androidx.compose.ui.Modifier | |
| 33 | 38 | 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 | |
| 34 | 42 | import androidx.compose.ui.graphics.graphicsLayer | |
| 35 | 43 | import androidx.compose.ui.platform.LocalContext | |
| 36 | 44 | import androidx.compose.ui.text.TextStyle | |
| 37 | 45 | import androidx.compose.ui.text.font.FontWeight | |
| 46 | + | import androidx.compose.ui.text.input.ImeAction | |
| 47 | + | import androidx.compose.ui.text.input.KeyboardCapitalization | |
| 38 | 48 | import androidx.compose.ui.unit.dp | |
| 39 | 49 | import androidx.compose.ui.unit.sp | |
| 40 | 50 | import androidx.compose.ui.window.Dialog | |
| @@ -63,12 +73,14 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 63 | 73 | .safeDrawingPadding(), | |
| 64 | 74 | ) { | |
| 65 | 75 | Header(app) | |
| 66 | − | PageWords(pages, pager.currentPage) { page -> | |
| 76 | + | val open = { page: Int -> | |
| 67 | 77 | scope.launch { | |
| 68 | 78 | if (look.motion) pager.animateScrollToPage(page, animationSpec = tween(Motion.PAGE, easing = Motion.EASE)) | |
| 69 | 79 | else pager.scrollToPage(page) | |
| 70 | 80 | } | |
| 81 | + | Unit | |
| 71 | 82 | } | |
| 83 | + | PageWords(pages, pager.currentPage, open) | |
| 72 | 84 | HorizontalPager(pager, Modifier.weight(1f)) { index -> | |
| 73 | 85 | Box( | |
| 74 | 86 | Modifier | |
| @@ -90,7 +102,8 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 90 | 102 | Caption(pages[index].caption) | |
| 91 | 103 | app.problem?.let { Problem(it) { app.read() } } | |
| 92 | 104 | when (pages[index]) { | |
| 93 | − | Page.Overview -> Overview(app, askPermission) | |
| 105 | + | Page.Overview -> Overview(app, askPermission) { open(pages.indexOf(it)) } | |
| 106 | + | Page.Equalizer -> Equalizer(app) | |
| 94 | 107 | Page.App -> AppPage(app) | |
| 95 | 108 | } | |
| 96 | 109 | Spacer(Modifier.size(12.dp)) | |
| @@ -106,6 +119,15 @@ fun Screen(app: App, askPermission: () -> Unit) { | |||
| 106 | 119 | app::answerDual, | |
| 107 | 120 | ) | |
| 108 | 121 | } | |
| 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) } | |
| 109 | 131 | } | |
| 110 | 132 | } | |
| 111 | 133 | ||
| @@ -169,6 +191,50 @@ private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) { | |||
| 169 | 191 | } | |
| 170 | 192 | } | |
| 171 | 193 | ||
| 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 | + | ||
| 172 | 238 | /** A question over the dimmed page: Cancel, or [action]. */ | |
| 173 | 239 | @Composable | |
| 174 | 240 | private fun Confirm(heading: String, words: String, action: String, answer: (Boolean) -> Unit) { | |
| @@ -193,6 +193,13 @@ fun Pill(words: String, filled: Boolean = false, onClick: () -> Unit) { | |||
| 193 | 193 | } | |
| 194 | 194 | } | |
| 195 | 195 | ||
| 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 | + | ||
| 196 | 203 | /** A value shown in a dark pill, like a firmware version or "NOT SET". */ | |
| 197 | 204 | @Composable | |
| 198 | 205 | fun ValueChip(words: String) { | |
| @@ -145,7 +145,7 @@ 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 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. | |
| 149 | 149 | *Done when* **A3** passes for every page. | |
| 150 | 150 | 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and | |
| 151 | 151 | tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key | |