Krolyxon
Commit 0155f71

Branches

Refs →

budctl0155f71

android: the desktop's look, the Overview and the APP page

Step 5 of docs/android.md, first part: the plain screen becomes the
desktop app's look, on a phone.

- Look.kt: the desktop's palette roles. The colors come from the
  phone's Material You palette (Android 14 names every role the desktop
  maps from Aurora; Android 12-13 use the tonal palettes), or from the
  built-in near-black one with a switch on the APP page. The fonts are
  read from crates/budctl-gui/fonts, which Gradle adds to the assets.
  The motion timings and curve are motion.rs's.
- Icons.kt, Dots.kt, Ui.kt: icons.rs's icons on the same 24-unit grid;
  the battery ring that lights dot after dot, the switch whose knob
  stretches into the fill, the dotted slider, the blinking light; cards,
  rows with the icon in a circle, segmented tracks, pills.
- Screen.kt: the header (name, a light for the state, Refresh), the
  pages as dot-matrix words, open one large with the live dot, and the
  pages, which fade into each other as they are swiped rather than
  slide. "Turn dual connection off?" asks first, as on the desktop.
- Overview.kt: battery, noise control with its strength, the switches
  and the bass level (the slider under its row on a phone).
- App.kt: the desktop's update in small: commands go to the worker in
  order, each answer clears the oldest, a row says "Saving..." until
  the buds' read-back arrives and takes no second write meanwhile, and
  a failure shows with Try again. Buds.kt reads the worker's events.
- AppPage.kt: colors, motion, the debug switch and log, and an About.
- `--ez demo true` shows made-up buds with no Bluetooth, for the
  emulator. Checked there in both palettes: the pages, the writes, the
  fades and the dialog.

No new dependencies.
Author
krolyxon
Date
2026-11-01 22:51 +0530
Parent
05f4013
Commit
0155f71d23074ff8d87bc7960b4dcf47bb1c3dcb

+1678 −185

12 files changed

  1. M android/app/build.gradle.kts +2 −0
  2. A android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +227 −0
  3. A android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt +75 −0
  4. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +206 −0
  5. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt +214 −0
  6. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt +182 −0
  7. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Look.kt +157 −0
  8. M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt +43 −180
  9. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +128 −0
  10. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +199 −0
  11. A android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt +233 −0
  12. M docs/android.md +12 −5
M android/app/build.gradle.kts View
@@ -91,6 +91,8 @@ androidComponents {
9191 doFirst { environment("ANDROID_NDK_HOME", ndk.get().asFile.path) }
9292 }
9393 variant.sources.jniLibs?.addGeneratedSourceDirectory(cargo, CargoNdk::output)
94+ // The desktop app's fonts and their license (OFL 1.1), shared rather than copied.
95+ variant.sources.assets?.addStaticSourceDirectory(workspace.resolve("crates/budctl-gui/fonts").path)
9496
9597 val check = tasks.register<CheckNoInternet>("check${name}NoInternet") {
9698 manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
A android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt View
@@ -0,0 +1,227 @@
1+package io.github.krolyxon.budctl
2+
3+import android.content.SharedPreferences
4+import androidx.compose.runtime.getValue
5+import androidx.compose.runtime.mutableStateListOf
6+import androidx.compose.runtime.mutableStateOf
7+import androidx.compose.runtime.setValue
8+import org.json.JSONObject
9+
10+/** The pages, in order: the dot-matrix word and the caption above the page. */
11+enum class Page(val word: String, val caption: String) {
12+ Overview("overview", "overview · battery, noise control, sound"),
13+ App("app", "app · colors, motion, debug log"),
14+}
15+
16+/** What a write is for. While one is on its way, its row says "Saving…" and takes no other. */
17+sealed interface Item {
18+ data object Read : Item
19+
20+ data object Noise : Item
21+
22+ data class Switch(val key: String) : Item
23+
24+ data object Bass : Item
25+}
26+
27+/** Where the app stands with the buds. */
28+sealed interface Connection {
29+ data object Demo : Connection
30+
31+ data object Connecting : Connection
32+
33+ data object Live : Connection
34+
35+ /** `kind` as the worker names it: `usage`, `not-found`, `busy` or `failed`. */
36+ data class Failed(val kind: String, val message: String) : Connection
37+}
38+
39+/**
40+ * What the screen shows and what a touch does, as the desktop app's `update` (crates/budctl-gui):
41+ * every change goes to the worker as a command, and the buds' read-back, the worker's answer,
42+ * changes the screen. With the demo buds, a change shows at once.
43+ */
44+class App(
45+ val demo: Boolean,
46+ private val prefs: SharedPreferences,
47+ /** Queues a command for the worker: null, or why it was not queued. */
48+ private val send: (String) -> String?,
49+ /** Starts the worker again, as after the debug switch. */
50+ private val restart: () -> Unit,
51+) {
52+ var buds by mutableStateOf(if (demo) Buds.DEMO else Buds.UNKNOWN)
53+ var connection by mutableStateOf<Connection>(if (demo) Connection.Demo else Connection.Connecting)
54+ var permitted by mutableStateOf(demo)
55+ var debug by mutableStateOf(prefs.getBoolean(DEBUG, false))
56+ private set
57+ var materialYou by mutableStateOf(prefs.getBoolean(MATERIAL_YOU, true))
58+ private set
59+ var motion by mutableStateOf(prefs.getBoolean(MOTION, true))
60+ private set
61+
62+ /** The noise-cancelling level to ask for: the buds' own, once they report one. */
63+ var ancLevel by mutableStateOf("deep")
64+ private set
65+
66+ /** A bass level moved on the screen and not set on the buds yet. */
67+ var bassDraft by mutableStateOf<Int?>(null)
68+ private set
69+
70+ /** "Turn dual connection off?" is open. */
71+ var confirmDual by mutableStateOf(false)
72+ private set
73+
74+ /** The commands sent and not answered yet, oldest first: each gets one event, in order. */
75+ private val inFlight = mutableStateListOf<Item>()
76+
77+ /** The worker's log lines, the debug dump with them. */
78+ val log = mutableStateListOf<String>()
79+
80+ val problem: String? get() = (connection as? Connection.Failed)?.message
81+
82+ fun saving(item: Item) = item in inFlight
83+
84+ fun read() {
85+ if (connection is Connection.Failed) connection = Connection.Connecting
86+ command(Item.Read, JSONObject().put("command", "read"))
87+ }
88+
89+ /** [mode] `off`, `transparency` or `anc`; [level] for `anc`, else the level the buds are at. */
90+ fun noise(mode: String, level: String? = null) {
91+ val json = JSONObject().put("command", "noise").put("mode", mode)
92+ if (mode == "anc") json.put("level", level ?: ancLevel)
93+ command(Item.Noise, json)
94+ }
95+
96+ fun switch(key: String, on: Boolean) {
97+ // Turning dual connection off may drop one of two connected devices: ask first.
98+ if (key == DUAL && !on) {
99+ confirmDual = true
100+ return
101+ }
102+ command(Item.Switch(key), JSONObject().put("command", "switch").put("key", key).put("on", on))
103+ }
104+
105+ fun answerDual(yes: Boolean) {
106+ confirmDual = false
107+ if (yes) command(Item.Switch(DUAL), JSONObject().put("command", "switch").put("key", DUAL).put("on", false))
108+ }
109+
110+ /** The bass level slider moved: shown at once, written when the finger lifts ([saveBass]). */
111+ fun moveBass(level: Int) {
112+ bassDraft = level
113+ }
114+
115+ fun saveBass() {
116+ val level = bassDraft ?: return
117+ if (level == buds.bass) {
118+ bassDraft = null
119+ return
120+ }
121+ command(Item.Bass, JSONObject().put("command", "bass").put("level", level))
122+ }
123+
124+ private fun command(item: Item, json: JSONObject) {
125+ if (item != Item.Read && item in inFlight) return
126+ if (demo) return pretend(item, json)
127+ inFlight += item
128+ send(json.toString())?.let { failure ->
129+ inFlight.remove(item)
130+ connection = Connection.Failed("failed", failure)
131+ }
132+ }
133+
134+ /** One event from the worker (docs/android.md#boundary), on the main thread. */
135+ fun answer(event: JSONObject) {
136+ when (event.getString("event")) {
137+ "found" -> buds = buds.copy(name = event.getString("name"))
138+ "lost" -> {
139+ connection = Connection.Failed("not-found", event.getString("message"))
140+ buds = buds.copy(connected = false)
141+ }
142+ else -> {
143+ val item = inFlight.removeFirstOrNull()
144+ when (event.getString("event")) {
145+ "read" -> {
146+ buds = buds.read(event)
147+ buds.noise?.level?.let { ancLevel = it }
148+ connection = Connection.Live
149+ }
150+ "noise" -> {
151+ val now = parseNoise(event)
152+ buds = buds.copy(noise = now)
153+ now.level?.let { ancLevel = it }
154+ connection = Connection.Live
155+ }
156+ "switch" -> {
157+ val now = parseSwitch(event.getJSONObject("switch"))
158+ buds = buds.copy(switches = buds.switches.map { if (it.key == now.key) now else it })
159+ connection = Connection.Live
160+ }
161+ "bass" -> {
162+ val level = event.getInt("level")
163+ buds = buds.copy(bass = level)
164+ if (bassDraft == level) bassDraft = null
165+ connection = Connection.Live
166+ }
167+ "failed" -> {
168+ val kind = event.getString("kind")
169+ connection = Connection.Failed(kind, event.getString("message"))
170+ if (kind == "not-found") buds = buds.copy(connected = false)
171+ if (item == Item.Bass) bassDraft = null
172+ }
173+ }
174+ }
175+ }
176+ }
177+
178+ /** The demo buds take a change at once. */
179+ private fun pretend(item: Item, json: JSONObject) {
180+ when (item) {
181+ Item.Read -> {}
182+ Item.Noise -> {
183+ val mode = json.getString("mode")
184+ val level = if (mode == "anc") json.getString("level") else buds.noise?.level
185+ buds = buds.copy(noise = NoiseControl(mode, level))
186+ level?.let { ancLevel = it }
187+ }
188+ is Item.Switch -> {
189+ val on = json.getBoolean("on")
190+ buds = buds.copy(switches = buds.switches.map { if (it.key == item.key) it.copy(on = on) else it })
191+ }
192+ Item.Bass -> {
193+ buds = buds.copy(bass = json.getInt("level"))
194+ bassDraft = null
195+ }
196+ }
197+ }
198+
199+ fun addLine(line: String) {
200+ log.add(line)
201+ if (log.size > MAX_LINES) log.removeRange(0, log.size - MAX_LINES)
202+ }
203+
204+ fun toggleDebug() {
205+ debug = !debug
206+ prefs.edit().putBoolean(DEBUG, debug).apply()
207+ if (!demo) restart()
208+ }
209+
210+ fun toggleMaterialYou() {
211+ materialYou = !materialYou
212+ prefs.edit().putBoolean(MATERIAL_YOU, materialYou).apply()
213+ }
214+
215+ fun toggleMotion() {
216+ motion = !motion
217+ prefs.edit().putBoolean(MOTION, motion).apply()
218+ }
219+
220+ private companion object {
221+ const val DUAL = "dual"
222+ const val DEBUG = "debug"
223+ const val MATERIAL_YOU = "material_you"
224+ const val MOTION = "motion"
225+ const val MAX_LINES = 1_000
226+ }
227+}
A android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt View
@@ -0,0 +1,75 @@
1+package io.github.krolyxon.budctl
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.text.BasicText
9+import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.compose.ui.platform.LocalContext
14+import androidx.compose.ui.text.TextStyle
15+import androidx.compose.ui.unit.dp
16+import androidx.compose.ui.unit.sp
17+
18+/** The app's own settings: colors, motion, the debug log, and what budctl is. */
19+@Composable
20+fun AppPage(app: App) {
21+ val look = LocalLook.current
22+ val context = LocalContext.current
23+ Heading("look")
24+ Card {
25+ SettingRow(
26+ Icon.Settings,
27+ "Material You colors",
28+ if (app.materialYou) "From your wallpaper" else "Nothing's near-black",
29+ onClick = app::toggleMaterialYou,
30+ ) { Toggle(app.materialYou) }
31+ SettingRow(
32+ Icon.Spatial,
33+ "Motion",
34+ "Fades, switches, the battery rings' sweep",
35+ onClick = app::toggleMotion,
36+ ) { Toggle(app.motion) }
37+ }
38+ if (!app.demo) {
39+ Heading("debug")
40+ Card {
41+ SettingRow(
42+ Icon.Chip,
43+ "Debug log",
44+ "Every frame to and from the buds, in hex",
45+ onClick = app::toggleDebug,
46+ ) { Toggle(app.debug) }
47+ }
48+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
49+ Note("The log of this session, to paste in a bug report.", Modifier.weight(1f))
50+ Pill("Copy log") {
51+ val clipboard = context.getSystemService(ClipboardManager::class.java)
52+ clipboard.setPrimaryClip(ClipData.newPlainText("budctl log", app.log.joinToString("\n")))
53+ }
54+ }
55+ if (app.log.isNotEmpty()) {
56+ Card {
57+ BasicText(
58+ app.log.takeLast(SHOWN_LINES).joinToString("\n"),
59+ Modifier.padding(14.dp),
60+ style = TextStyle(fontFamily = look.fonts.mono, fontSize = 10.sp, color = look.p.fgDim),
61+ )
62+ }
63+ }
64+ }
65+ Heading("about")
66+ val version = remember { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "?" }
67+ Card {
68+ SettingRow(Icon.Info, "Version") { ValueChip(version) }
69+ SettingRow(Icon.Phone, "No network access", "budctl has no internet permission")
70+ SettingRow(Icon.Chip, "License", "GPL-3.0-or-later")
71+ }
72+}
73+
74+/** The log lines the page shows; Copy log copies them all. */
75+private const val SHOWN_LINES = 60
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt View
@@ -0,0 +1,206 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.runtime.Immutable
4+import org.json.JSONArray
5+import org.json.JSONObject
6+
7+data class Charge(val level: Int, val charging: Boolean)
8+
9+/** `off`, `transparency` or `anc`, and the noise-cancelling level when the buds report one. */
10+data class NoiseControl(val mode: String, val level: String?)
11+
12+data class Switch(val key: String, val label: String, val on: Boolean)
13+
14+data class EqBand(val hz: Int, val gain: Int)
15+
16+data class EqPreset(val name: String, val id: Int, val custom: Boolean, val bands: List<EqBand>, val range: IntRange?)
17+
18+data class EqPresets(val current: String?, val presets: List<EqPreset>)
19+
20+data class Gesture(val side: String, val gesture: String, val action: String, val group: Int?)
21+
22+data class GestureChoice(val gesture: String, val actions: List<String>)
23+
24+data class TouchControls(
25+ val gestures: List<Gesture>,
26+ val holdCycle: List<String>?,
27+ val choices: List<GestureChoice>,
28+ val cycleModes: List<String>,
29+)
30+
31+/** A phone or computer the buds keep for dual connection. [thisDevice]: the one that asked. */
32+data class DualDevice(
33+ val address: String,
34+ val name: String,
35+ val connected: Boolean,
36+ val thisDevice: Boolean,
37+ val mainAudio: Boolean,
38+ val playing: Boolean,
39+)
40+
41+/**
42+ * The buds the screen shows: made-up ones for the demo, an empty pair until the real buds answer,
43+ * then what they report (the worker's `read` event, docs/android.md#boundary).
44+ */
45+@Immutable
46+data class Buds(
47+ val name: String,
48+ val connected: Boolean,
49+ val left: Charge? = null,
50+ val right: Charge? = null,
51+ val case: Charge? = null,
52+ val noise: NoiseControl? = null,
53+ /** The EQ preset in use: a built-in one's name, or `custom-<id>`. */
54+ val eq: String? = null,
55+ val switches: List<Switch> = emptyList(),
56+ val codec: String? = null,
57+ val firmware: String? = null,
58+ val presets: EqPresets? = null,
59+ val touch: TouchControls? = null,
60+ /** The levels the bass level takes, when the buds have one to set. */
61+ val bassRange: IntRange? = null,
62+ /** The bass level last set from this app: the buds do not report it. */
63+ val bass: Int? = null,
64+ val dual: List<DualDevice>? = null,
65+) {
66+ /** The name to show for the EQ preset in use (crates/budctl-gui/src/buds.rs, `eq_name`). */
67+ fun eqName(): String {
68+ val current = eq ?: return "Unknown"
69+ val preset = presets?.presets?.find { key(it) == current } ?: return words(current)
70+ return shownName(preset)
71+ }
72+
73+ /** The buds as a reading says, keeping what the app knows and the buds do not report. */
74+ fun read(reading: JSONObject): Buds {
75+ val status = reading.getJSONObject("status")
76+ val battery = status.optJSONObject("battery")
77+ return copy(
78+ connected = true,
79+ left = battery?.optJSONObject("left")?.let(::charge),
80+ right = battery?.optJSONObject("right")?.let(::charge),
81+ case = battery?.optJSONObject("case")?.let(::charge),
82+ noise = status.optJSONObject("noise_control")?.let(::parseNoise),
83+ eq = status.text("eq"),
84+ switches = status.optJSONArray("switches").objects().map(::parseSwitch),
85+ codec = status.text("codec"),
86+ firmware = status.text("firmware"),
87+ presets = reading.optJSONObject("eq")?.let(::presets),
88+ touch = reading.optJSONObject("touch")?.let(::touch),
89+ bassRange = reading.optJSONArray("bass_range")?.let { it.getInt(0)..it.getInt(1) },
90+ dual = reading.optJSONArray("dual")?.objects()?.map(::dual),
91+ )
92+ }
93+
94+ companion object {
95+ /** What the screen shows before the real buds answer. */
96+ val UNKNOWN = Buds(name = "Your buds", connected = false)
97+
98+ /** The made-up buds of the demo: roughly what the owner's T310 reports. */
99+ val DEMO = Buds(
100+ name = "realme Buds T310",
101+ connected = true,
102+ left = Charge(80, false),
103+ right = Charge(80, false),
104+ noise = NoiseControl("anc", "deep"),
105+ eq = "custom-4",
106+ switches = listOf(
107+ Switch("game", "Game mode", false),
108+ Switch("dual", "Dual connection", true),
109+ Switch("wind", "Wind-noise reduction", true),
110+ Switch("spatial", "3D sound", true),
111+ Switch("bass-boost", "Bass boost", true),
112+ ),
113+ codec = "SBC",
114+ firmware = "1.1.0.43",
115+ presets = EqPresets(
116+ "custom-4",
117+ listOf("pure-raw", "deep-bass", "pure-vocals", "pure-bass").mapIndexed { id, name ->
118+ EqPreset(name, id, false, emptyList(), null)
119+ } + EqPreset(
120+ "Custom1",
121+ 4,
122+ true,
123+ listOf(EqBand(50, 3), EqBand(250, 1), EqBand(1000, 0), EqBand(4000, -1), EqBand(8000, 1), EqBand(16000, 2)),
124+ -6..6,
125+ ),
126+ ),
127+ touch = TouchControls(
128+ gestures = listOf(
129+ Gesture("left", "double-tap", "play-pause", null),
130+ Gesture("left", "triple-tap", "previous", null),
131+ Gesture("left", "hold", "noise-cycle", null),
132+ Gesture("right", "double-tap", "play-pause", null),
133+ Gesture("right", "triple-tap", "next", null),
134+ Gesture("right", "hold", "noise-cycle", null),
135+ ),
136+ holdCycle = listOf("off", "anc"),
137+ choices = listOf(
138+ GestureChoice("double-tap", listOf("none", "play-pause", "next", "voice-assistant")),
139+ GestureChoice(
140+ "triple-tap",
141+ listOf("none", "previous", "next", "voice-assistant", "volume-up", "volume-down", "game-mode"),
142+ ),
143+ GestureChoice("hold", listOf("none", "noise-cycle", "voice-assistant", "game-mode")),
144+ ),
145+ cycleModes = listOf("off", "transparency", "anc"),
146+ ),
147+ bassRange = -5..5,
148+ dual = listOf(
149+ DualDevice("2B:1A:00:AA:BB:CC", "arch", connected = true, thisDevice = false, mainAudio = false, playing = false),
150+ DualDevice("11:22:33:44:55:66", "Phone", connected = true, thisDevice = true, mainAudio = true, playing = true),
151+ ),
152+ )
153+
154+ /** The name a preset is chosen by: a built-in one's own, `custom-<id>` for a custom one. */
155+ fun key(preset: EqPreset) = if (preset.custom) "custom-${preset.id}" else preset.name
156+
157+ /** A preset's name to show: a custom one's stored name, a built-in one's in words. */
158+ fun shownName(preset: EqPreset) = if (preset.custom) preset.name else words(preset.name)
159+
160+ /** `pure-bass` → "Pure bass". */
161+ fun words(name: String) = name.replace('-', ' ').replaceFirstChar { it.uppercase() }
162+ }
163+}
164+
165+/** A string field, or null when it is missing or JSON null (org.json's optString says "null"). */
166+private fun JSONObject.text(name: String): String? = if (isNull(name)) null else optString(name)
167+
168+private fun JSONArray?.objects(): List<JSONObject> = if (this == null) emptyList() else List(length()) { getJSONObject(it) }
169+
170+private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else List(length()) { getString(it) }
171+
172+private fun charge(json: JSONObject) = Charge(json.getInt("level"), json.getBoolean("charging"))
173+
174+/** A noise control, as a reading and the `noise` event give it. */
175+internal fun parseNoise(json: JSONObject) = NoiseControl(json.getString("mode"), json.text("level"))
176+
177+/** A switch, as a reading and the `switch` event give it. */
178+internal fun parseSwitch(json: JSONObject) = Switch(json.getString("key"), json.getString("label"), json.getBoolean("on"))
179+
180+private fun preset(json: JSONObject) = EqPreset(
181+ json.getString("name"),
182+ json.getInt("id"),
183+ json.getBoolean("custom"),
184+ json.optJSONArray("bands").objects().map { EqBand(it.getInt("hz"), it.getInt("gain")) },
185+ json.optJSONArray("range")?.let { it.getInt(0)..it.getInt(1) },
186+)
187+
188+private fun presets(json: JSONObject) = EqPresets(json.text("current"), json.optJSONArray("presets").objects().map(::preset))
189+
190+private fun touch(json: JSONObject) = TouchControls(
191+ gestures = json.optJSONArray("gestures").objects().map {
192+ Gesture(it.getString("side"), it.getString("gesture"), it.getString("action"), if (it.isNull("group")) null else it.getInt("group"))
193+ },
194+ holdCycle = json.optJSONArray("hold_cycle")?.strings(),
195+ choices = json.optJSONArray("choices").objects().map { GestureChoice(it.getString("gesture"), it.optJSONArray("actions").strings()) },
196+ cycleModes = json.optJSONArray("cycle_modes").strings(),
197+)
198+
199+private fun dual(json: JSONObject) = DualDevice(
200+ json.getString("address"),
201+ json.getString("name"),
202+ json.getBoolean("connected"),
203+ json.getBoolean("this_computer"),
204+ json.getBoolean("main_audio"),
205+ json.getBoolean("playing"),
206+)
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt View
@@ -0,0 +1,214 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.animation.core.Animatable
4+import androidx.compose.animation.core.RepeatMode
5+import androidx.compose.animation.core.animateFloat
6+import androidx.compose.animation.core.animateFloatAsState
7+import androidx.compose.animation.core.infiniteRepeatable
8+import androidx.compose.animation.core.keyframes
9+import androidx.compose.animation.core.rememberInfiniteTransition
10+import androidx.compose.animation.core.tween
11+import androidx.compose.foundation.Canvas
12+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
13+import androidx.compose.foundation.gestures.detectTapGestures
14+import androidx.compose.foundation.layout.Box
15+import androidx.compose.foundation.layout.BoxWithConstraints
16+import androidx.compose.foundation.layout.aspectRatio
17+import androidx.compose.foundation.layout.fillMaxWidth
18+import androidx.compose.foundation.layout.height
19+import androidx.compose.foundation.layout.size
20+import androidx.compose.foundation.text.BasicText
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.LaunchedEffect
23+import androidx.compose.runtime.getValue
24+import androidx.compose.runtime.mutableStateOf
25+import androidx.compose.runtime.remember
26+import androidx.compose.runtime.rememberUpdatedState
27+import androidx.compose.runtime.setValue
28+import androidx.compose.ui.Alignment
29+import androidx.compose.ui.Modifier
30+import androidx.compose.ui.geometry.CornerRadius
31+import androidx.compose.ui.geometry.Offset
32+import androidx.compose.ui.geometry.Size
33+import androidx.compose.ui.graphics.Color
34+import androidx.compose.ui.graphics.lerp
35+import androidx.compose.ui.input.pointer.pointerInput
36+import androidx.compose.ui.text.TextStyle
37+import androidx.compose.ui.unit.Dp
38+import androidx.compose.ui.unit.dp
39+import androidx.compose.ui.unit.sp
40+import androidx.compose.ui.util.lerp
41+import kotlin.math.PI
42+import kotlin.math.cos
43+import kotlin.math.max
44+import kotlin.math.roundToInt
45+import kotlin.math.sin
46+
47+/** The dots in a battery ring. */
48+private const val RING_DOTS = 44
49+
50+/** How many of [n] dots the fraction [f] lights. */
51+private fun litCount(f: Float, n: Int) = (f.coerceIn(0f, 1f) * n).roundToInt().coerceAtMost(n)
52+
53+/**
54+ * A battery ring as wide as it is given: a ring of dots, lit clockwise from the top up to the
55+ * [level] (a percentage), with the level in LED dots in the middle; "--" and no lit dot when it
56+ * is unknown. The dots light one after the other, like the Glyph lights, when the ring first
57+ * shows and when the level changes.
58+ */
59+@Composable
60+fun Ring(level: Int?, modifier: Modifier = Modifier) {
61+ val look = LocalLook.current
62+ val p = look.p
63+ val target = level?.let { litCount(it / 100f, RING_DOTS) } ?: 0
64+ val lit = remember { Animatable(0f) }
65+ LaunchedEffect(target, look.motion) {
66+ if (look.motion) lit.animateTo(target.toFloat(), tween(Motion.SWEEP, easing = Motion.EASE))
67+ else lit.snapTo(target.toFloat())
68+ }
69+ BoxWithConstraints(modifier.aspectRatio(1f), contentAlignment = Alignment.Center) {
70+ Canvas(Modifier.size(maxWidth)) {
71+ val dot = max(size.width * 0.045f, 3.dp.toPx())
72+ val radius = size.width / 2 - dot / 2 - 1
73+ val on = lit.value.roundToInt()
74+ for (n in 0 until RING_DOTS) {
75+ val angle = n.toFloat() / RING_DOTS * 2 * PI.toFloat() - PI.toFloat() / 2
76+ val at = Offset(center.x + radius * cos(angle), center.y + radius * sin(angle))
77+ drawCircle(if (n < on) p.fill else p.s3, dot / 2, at)
78+ }
79+ }
80+ // Doto is never set under 26 sp.
81+ val figure = max(26f, maxWidth.value * 0.28f).sp
82+ BasicText(
83+ level?.toString() ?: "--",
84+ style = TextStyle(fontFamily = look.fonts.dot, fontSize = figure, color = if (level != null) p.fg else p.mute),
85+ )
86+ }
87+}
88+
89+/**
90+ * An on/off switch: off, a dark pill with a dim knob on the left; on, the fill runs to the right
91+ * end, with a dot in it. Turned, the knob stretches into the fill (or back). It only shows the
92+ * state: the row around it takes the touch.
93+ */
94+@Composable
95+fun Toggle(on: Boolean) {
96+ val look = LocalLook.current
97+ val p = look.p
98+ val t by animateFloatAsState(if (on) 1f else 0f, Motion.eased(Motion.STATE, look.motion), label = "switch")
99+ Canvas(Modifier.size(60.dp, 32.dp)) {
100+ drawRoundRect(p.s2, cornerRadius = CornerRadius(size.height / 2))
101+ val pad = 4.dp.toPx()
102+ val height = size.height - 2 * pad
103+ val width = lerp(height, size.width - 2 * pad, t)
104+ drawRoundRect(lerp(p.mute, p.fill, t), Offset(pad, pad), Size(width, height), CornerRadius(height / 2))
105+ if (t > 0f) {
106+ val r = 4.5.dp.toPx()
107+ drawCircle(p.fillFg.copy(alpha = p.fillFg.alpha * t), r, Offset(pad + width - 7.dp.toPx() - r, size.height / 2))
108+ }
109+ }
110+}
111+
112+/** A dotted slider's dots: the value's, and the others. */
113+private val DOT = 5.dp
114+private val DOT_BIG = 11.dp
115+
116+/**
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.
120+ */
121+@Composable
122+fun DotSlider(low: Int, high: Int, value: Int, change: (Int) -> Unit, release: () -> Unit, modifier: Modifier = Modifier) {
123+ val look = LocalLook.current
124+ val p = look.p
125+ val onChange by rememberUpdatedState(change)
126+ val onRelease by rememberUpdatedState(release)
127+ // The value's dot grows; the one it left shrinks back.
128+ var left by remember { mutableStateOf<Int?>(null) }
129+ var shown by remember { mutableStateOf(value) }
130+ val grow = remember { Animatable(1f) }
131+ LaunchedEffect(value) {
132+ if (value != shown) {
133+ left = shown
134+ shown = value
135+ grow.snapTo(0f)
136+ grow.animateTo(1f, Motion.eased(Motion.HOVER, look.motion))
137+ }
138+ }
139+ val steps = max(1, high - low)
140+ 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))
152+ onRelease()
153+ }
154+ }
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, _ ->
162+ change.consume()
163+ onChange(at(change.position.x))
164+ }
165+ },
166+ ) {
167+ val pad = DOT_BIG.toPx()
168+ val base = low.coerceAtLeast(0).coerceAtMost(high)
169+ val from = minOf(base, value)
170+ val to = maxOf(base, value)
171+ val g = grow.value
172+ 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)
178+ else -> DOT.toPx()
179+ }
180+ drawCircle(if (lit || v == value) p.fill else p.s3, diameter / 2, Offset(x, size.height / 2))
181+ }
182+ }
183+}
184+
185+/** A round light [size] across; [blinking], it is lit and dark in turn, like a recording light. */
186+@Composable
187+fun Light(color: Color, size: Dp, blinking: Boolean = false) {
188+ val look = LocalLook.current
189+ val alpha = if (blinking && look.motion) {
190+ val blink = rememberInfiniteTransition(label = "blink")
191+ // Hard steps, no fade: lit for one beat, dark for the next.
192+ val a by blink.animateFloat(
193+ 1f,
194+ 0f,
195+ infiniteRepeatable(
196+ keyframes {
197+ durationMillis = 2 * Motion.BLINK
198+ 1f at 0
199+ 1f at Motion.BLINK - 1
200+ 0f at Motion.BLINK
201+ 0f at 2 * Motion.BLINK - 1
202+ },
203+ RepeatMode.Restart,
204+ ),
205+ label = "blink",
206+ )
207+ a
208+ } else {
209+ 1f
210+ }
211+ Box(Modifier.size(size)) {
212+ Canvas(Modifier.size(size)) { drawCircle(color.copy(alpha = color.alpha * alpha)) }
213+ }
214+}
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt View
@@ -0,0 +1,182 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.foundation.Canvas
4+import androidx.compose.foundation.layout.size
5+import androidx.compose.runtime.Composable
6+import androidx.compose.ui.Modifier
7+import androidx.compose.ui.geometry.CornerRadius
8+import androidx.compose.ui.geometry.Offset
9+import androidx.compose.ui.geometry.Size
10+import androidx.compose.ui.graphics.Color
11+import androidx.compose.ui.graphics.Path
12+import androidx.compose.ui.graphics.StrokeCap
13+import androidx.compose.ui.graphics.StrokeJoin
14+import androidx.compose.ui.graphics.drawscope.DrawScope
15+import androidx.compose.ui.graphics.drawscope.Stroke
16+import androidx.compose.ui.unit.Dp
17+import kotlin.math.cos
18+import kotlin.math.sin
19+
20+/** The desktop app's icons (crates/budctl-gui/src/icons.rs), as paths on a 24 × 24 grid. */
21+enum class Icon {
22+ Equalizer, Gestures, Earbud, Settings, NoiseOff, Anc, Transparency, Game, Spatial, Bass, Wind,
23+ Dual, Chevron, Bolt, Find, Info, Chip, Pencil, Trash, Laptop, Phone,
24+}
25+
26+/** [icon], [size] across, in [color]. */
27+@Composable
28+fun Glyph(icon: Icon, size: Dp, color: Color, modifier: Modifier = Modifier) {
29+ Canvas(modifier.size(size)) { Pen(this, color).draw(icon) }
30+}
31+
32+/** Draws on the 24 × 24 grid, scaled to the canvas, with round caps and joins. */
33+private class Pen(private val scope: DrawScope, private val color: Color) {
34+ private val scale = scope.size.width / 24f
35+ private val stroke = Stroke(width = 1.9f * scale, cap = StrokeCap.Round, join = StrokeJoin.Round)
36+
37+ private fun at(x: Float, y: Float) = Offset(x * scale, y * scale)
38+
39+ fun polyline(points: List<Pair<Float, Float>>, closed: Boolean = false) {
40+ val path = Path()
41+ points.forEachIndexed { n, (x, y) ->
42+ if (n == 0) path.moveTo(x * scale, y * scale) else path.lineTo(x * scale, y * scale)
43+ }
44+ if (closed) path.close()
45+ scope.drawPath(path, color, style = stroke)
46+ }
47+
48+ fun line(from: Pair<Float, Float>, to: Pair<Float, Float>) = polyline(listOf(from, to))
49+
50+ fun circle(x: Float, y: Float, r: Float) = scope.drawCircle(color, r * scale, at(x, y), style = stroke)
51+
52+ fun dot(x: Float, y: Float, r: Float) = scope.drawCircle(color, r * scale, at(x, y))
53+
54+ /** Degrees, clockwise from three o'clock; [to] below [from] goes the other way. */
55+ fun arc(x: Float, y: Float, r: Float, from: Float, to: Float) = scope.drawArc(
56+ color,
57+ startAngle = from,
58+ sweepAngle = to - from,
59+ useCenter = false,
60+ topLeft = at(x - r, y - r),
61+ size = Size(2 * r * scale, 2 * r * scale),
62+ style = stroke,
63+ )
64+
65+ fun rect(x: Float, y: Float, w: Float, h: Float, r: Float) =
66+ scope.drawRoundRect(color, at(x, y), Size(w * scale, h * scale), CornerRadius(r * scale), style = stroke)
67+
68+ fun fillRect(x: Float, y: Float, w: Float, h: Float, r: Float) =
69+ scope.drawRoundRect(color, at(x, y), Size(w * scale, h * scale), CornerRadius(r * scale))
70+
71+ fun draw(icon: Icon) {
72+ when (icon) {
73+ Icon.Equalizer -> for ((x, knob) in listOf(6f to 14f, 12f to 8f, 18f to 16f)) {
74+ line(x to 3.5f, x to 20.5f)
75+ line(x - 2.5f to knob, x + 2.5f to knob)
76+ }
77+ Icon.Gestures -> {
78+ rect(9.5f, 11f, 5f, 11f, 2.5f)
79+ arc(12f, 11.5f, 4f, 200f, 340f)
80+ arc(12f, 11.5f, 7.5f, 210f, 330f)
81+ }
82+ Icon.Earbud -> {
83+ fillRect(14.5f, 5f, 6f, 7f, 2.8f)
84+ dot(11f, 8.5f, 6.3f)
85+ fillRect(6.6f, 10f, 4.4f, 12.5f, 2.2f)
86+ }
87+ Icon.Settings -> for ((y, knob) in listOf(6f to 15f, 12f to 8f, 18f to 13f)) {
88+ line(3.5f to y, 20.5f to y)
89+ dot(knob, y, 2.6f)
90+ }
91+ Icon.NoiseOff -> {
92+ circle(12f, 12f, 8.5f)
93+ line(6f to 6f, 18f to 18f)
94+ }
95+ Icon.Anc -> {
96+ circle(12f, 12f, 8.5f)
97+ circle(12f, 12f, 4.5f)
98+ dot(12f, 12f, 1.4f)
99+ }
100+ Icon.Transparency -> {
101+ for (n in 0 until 12) {
102+ val angle = Math.toRadians(n * 30.0)
103+ dot(12f + 8.5f * cos(angle).toFloat(), 12f + 8.5f * sin(angle).toFloat(), 1.15f)
104+ }
105+ dot(12f, 12f, 2.2f)
106+ }
107+ Icon.Game, Icon.Bolt -> polyline(
108+ listOf(13.5f to 2.5f, 5f to 13.5f, 11.5f to 13.5f, 10.5f to 21.5f, 19f to 10.5f, 12.5f to 10.5f),
109+ closed = true,
110+ )
111+ Icon.Spatial -> {
112+ dot(12f, 12f, 2.4f)
113+ for ((r, spread) in listOf(5.5f to 40f, 9.5f to 35f)) {
114+ arc(12f, 12f, r, -spread, spread)
115+ arc(12f, 12f, r, 180f - spread, 180f + spread)
116+ }
117+ }
118+ Icon.Bass -> {
119+ polyline(
120+ listOf(3.5f to 9.5f, 7.5f to 9.5f, 12.5f to 5f, 12.5f to 19f, 7.5f to 14.5f, 3.5f to 14.5f),
121+ closed = true,
122+ )
123+ arc(13f, 12f, 4f, -50f, 50f)
124+ arc(13f, 12f, 8f, -50f, 50f)
125+ }
126+ Icon.Wind -> {
127+ line(3f to 8.5f, 13f to 8.5f)
128+ arc(13f, 6f, 2.5f, 90f, -180f)
129+ line(3f to 12.5f, 17.5f to 12.5f)
130+ arc(17.5f, 15.5f, 3f, -90f, 180f)
131+ line(3f to 16.5f, 9f to 16.5f)
132+ }
133+ Icon.Dual -> {
134+ rect(2.5f, 5f, 12.5f, 9f, 1.5f)
135+ line(1.5f to 17.5f, 16f to 17.5f)
136+ rect(17.5f, 8f, 4.5f, 11f, 1.2f)
137+ }
138+ Icon.Chevron -> polyline(listOf(9f to 5.5f, 15.5f to 12f, 9f to 18.5f))
139+ Icon.Find -> {
140+ circle(12f, 12f, 6.5f)
141+ dot(12f, 12f, 2f)
142+ line(12f to 2f, 12f to 5f)
143+ line(12f to 19f, 12f to 22f)
144+ line(2f to 12f, 5f to 12f)
145+ line(19f to 12f, 22f to 12f)
146+ }
147+ Icon.Pencil -> {
148+ polyline(listOf(4.5f to 19.5f, 5.5f to 15f, 15.5f to 5f, 19f to 8.5f, 9f to 18.5f), closed = true)
149+ line(13f to 7.5f, 16.5f to 11f)
150+ }
151+ Icon.Trash -> {
152+ line(4f to 6.5f, 20f to 6.5f)
153+ polyline(listOf(9f to 6.5f, 9.5f to 3.5f, 14.5f to 3.5f, 15f to 6.5f))
154+ polyline(listOf(6f to 6.5f, 7f to 20.5f, 17f to 20.5f, 18f to 6.5f))
155+ line(10f to 10.5f, 10f to 16.5f)
156+ line(14f to 10.5f, 14f to 16.5f)
157+ }
158+ Icon.Info -> {
159+ circle(12f, 12f, 9f)
160+ line(12f to 11f, 12f to 16.5f)
161+ dot(12f, 7.8f, 1.2f)
162+ }
163+ Icon.Chip -> {
164+ rect(6.5f, 6.5f, 11f, 11f, 2f)
165+ for (at in listOf(9.5f, 14.5f)) {
166+ line(at to 2.5f, at to 6.5f)
167+ line(at to 17.5f, at to 21.5f)
168+ line(2.5f to at, 6.5f to at)
169+ line(17.5f to at, 21.5f to at)
170+ }
171+ }
172+ Icon.Laptop -> {
173+ rect(4f, 5f, 16f, 11f, 1.5f)
174+ line(2f to 19f, 22f to 19f)
175+ }
176+ Icon.Phone -> {
177+ rect(7f, 2.5f, 10f, 19f, 2f)
178+ line(10.5f to 18.5f, 13.5f to 18.5f)
179+ }
180+ }
181+ }
182+}
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Look.kt View
@@ -0,0 +1,157 @@
1+package io.github.krolyxon.budctl
2+
3+import android.content.Context
4+import android.content.res.AssetManager
5+import android.os.Build
6+import androidx.compose.animation.core.AnimationSpec
7+import androidx.compose.animation.core.CubicBezierEasing
8+import androidx.compose.animation.core.snap
9+import androidx.compose.animation.core.tween
10+import androidx.compose.runtime.Immutable
11+import androidx.compose.runtime.staticCompositionLocalOf
12+import androidx.compose.ui.graphics.Color
13+import androidx.compose.ui.text.font.Font
14+import androidx.compose.ui.text.font.FontFamily
15+import androidx.compose.ui.text.font.FontWeight
16+
17+/**
18+ * The desktop app's look (CLAUDE.md, GUI): Nothing OS's shapes and type in Material You colors.
19+ * The colors come by role, as in crates/budctl-gui/src/theme.rs: a ladder of surfaces, and the
20+ * "on" states on top.
21+ */
22+@Immutable
23+data class Palette(
24+ /** The page (`surfaceLowest`). */
25+ val bg: Color,
26+ /** Cards (`surfaceLow`). */
27+ val s1: Color,
28+ /** Icon circles, tracks, the switches' "off" fill (`surfaceHigh`). */
29+ val s2: Color,
30+ /** Unlit dots, pressed rows (`surfaceHighest`). */
31+ val s3: Color,
32+ /** Inactive things: the pages not open, an off switch's knob (`outlineVariant`). */
33+ val mute: Color,
34+ /** Caps labels (`outline`). */
35+ val grey: Color,
36+ /** Text (`onSurface`). */
37+ val fg: Color,
38+ /** Secondary text (`onSurfaceVariant`). */
39+ val fgDim: Color,
40+ /** What is on or chosen: switches, the chosen segment, lit dots (`primary`). */
41+ val fill: Color,
42+ /** Text and icons on [fill] (`onPrimary`). */
43+ val fillFg: Color,
44+ /** Live things, one at a time: the open page's dot, "Saving…", charging (`tertiary`). */
45+ val live: Color,
46+ val liveFg: Color,
47+ val err: Color,
48+ val errFg: Color,
49+)
50+
51+/** Near-black surfaces, white "on" states and a red for live things: the desktop's own. */
52+val BUILT_IN = Palette(
53+ bg = Color(0xFF000000),
54+ s1 = Color(0xFF111112),
55+ s2 = Color(0xFF1C1C1E),
56+ s3 = Color(0xFF2A2A2D),
57+ mute = Color(0xFF454549),
58+ grey = Color(0xFF8E8E93),
59+ fg = Color(0xFFF2F2F2),
60+ fgDim = Color(0xFFB8B8BC),
61+ fill = Color(0xFFF2F2F2),
62+ fillFg = Color(0xFF000000),
63+ live = Color(0xFFD71921),
64+ liveFg = Color(0xFFFFFFFF),
65+ err = Color(0xFFFFB4AB),
66+ errFg = Color(0xFF690005),
67+)
68+
69+/**
70+ * The phone's Material You colors (from the wallpaper), dark, in the same roles as the desktop
71+ * maps Aurora's palette. Android 14 names every role; before it, the tonal palettes stand in.
72+ */
73+fun materialYou(context: Context): Palette {
74+ fun c(id: Int) = Color(context.getColor(id))
75+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
76+ Palette(
77+ bg = c(android.R.color.system_surface_container_lowest_dark),
78+ s1 = c(android.R.color.system_surface_container_low_dark),
79+ s2 = c(android.R.color.system_surface_container_high_dark),
80+ s3 = c(android.R.color.system_surface_container_highest_dark),
81+ mute = c(android.R.color.system_outline_variant_dark),
82+ grey = c(android.R.color.system_outline_dark),
83+ fg = c(android.R.color.system_on_surface_dark),
84+ fgDim = c(android.R.color.system_on_surface_variant_dark),
85+ fill = c(android.R.color.system_primary_dark),
86+ fillFg = c(android.R.color.system_on_primary_dark),
87+ live = c(android.R.color.system_tertiary_dark),
88+ liveFg = c(android.R.color.system_on_tertiary_dark),
89+ err = c(android.R.color.system_error_dark),
90+ errFg = c(android.R.color.system_on_error_dark),
91+ )
92+ } else {
93+ Palette(
94+ bg = c(android.R.color.system_neutral1_1000),
95+ s1 = c(android.R.color.system_neutral1_900),
96+ s2 = c(android.R.color.system_neutral1_800),
97+ s3 = c(android.R.color.system_neutral1_700),
98+ mute = c(android.R.color.system_neutral2_700),
99+ grey = c(android.R.color.system_neutral2_400),
100+ fg = c(android.R.color.system_neutral1_100),
101+ fgDim = c(android.R.color.system_neutral2_200),
102+ fill = c(android.R.color.system_accent1_200),
103+ fillFg = c(android.R.color.system_accent1_800),
104+ live = c(android.R.color.system_accent3_200),
105+ liveFg = c(android.R.color.system_accent3_800),
106+ err = BUILT_IN.err,
107+ errFg = BUILT_IN.errFg,
108+ )
109+ }
110+}
111+
112+/**
113+ * The desktop app's fonts, read from crates/budctl-gui/fonts (OFL 1.1), which Gradle adds to the
114+ * app's assets: Space Grotesk for text, Space Mono for caps labels, and Doto Rounded for numbers
115+ * and page words. Doto is never set under 26 sp: smaller, the dots blur.
116+ */
117+class Fonts(assets: AssetManager) {
118+ val ui = FontFamily(
119+ Font("SpaceGrotesk-Regular.otf", assets, FontWeight.Normal),
120+ Font("SpaceGrotesk-Medium.otf", assets, FontWeight.Medium),
121+ Font("SpaceGrotesk-SemiBold.otf", assets, FontWeight.SemiBold),
122+ )
123+ val mono = FontFamily(Font("SpaceMono-Regular.ttf", assets))
124+ val dot = FontFamily(Font("DotoRounded-Black.ttf", assets, FontWeight.Black))
125+}
126+
127+/** What the screen draws with: the colors, the fonts, and whether things move. */
128+@Immutable
129+data class Look(val p: Palette, val fonts: Fonts, val motion: Boolean)
130+
131+val LocalLook = staticCompositionLocalOf<Look> { error("no Look provided") }
132+
133+/**
134+ * Nothing's motion (crates/budctl-gui/src/motion.rs): 150-250 ms, eased out on
135+ * `cubic-bezier(0.25, 0.1, 0.25, 1)`, no bounce; things fade rather than slide.
136+ */
137+object Motion {
138+ val EASE = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
139+
140+ /** A press and a small change of state. */
141+ const val HOVER = 150
142+
143+ /** A switch turning, a choice changing. */
144+ const val STATE = 200
145+
146+ /** A page fading in. */
147+ const val PAGE = 240
148+
149+ /** The battery rings lighting up, dot after dot. */
150+ const val SWEEP = 600
151+
152+ /** A blinking light: lit, then dark, this long each. */
153+ const val BLINK = 500
154+
155+ fun <T> eased(millis: Int, motion: Boolean): AnimationSpec<T> =
156+ if (motion) tween(millis, easing = EASE) else snap()
157+}
M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt View
@@ -1,231 +1,94 @@
11 package io.github.krolyxon.budctl
22
33 import android.Manifest
4−import android.content.ClipData
5−import android.content.ClipboardManager
64 import android.content.pm.PackageManager
5+import android.graphics.Color
76 import android.os.Bundle
87 import android.util.Log
98 import androidx.activity.ComponentActivity
9+import androidx.activity.SystemBarStyle
1010 import androidx.activity.compose.setContent
1111 import androidx.activity.enableEdgeToEdge
1212 import androidx.activity.result.contract.ActivityResultContracts
13−import androidx.compose.foundation.background
14−import androidx.compose.foundation.clickable
15−import androidx.compose.foundation.layout.Arrangement
16−import androidx.compose.foundation.layout.Column
17−import androidx.compose.foundation.layout.Row
18−import androidx.compose.foundation.layout.fillMaxSize
19−import androidx.compose.foundation.layout.padding
20−import androidx.compose.foundation.layout.safeDrawingPadding
21−import androidx.compose.foundation.rememberScrollState
22−import androidx.compose.foundation.shape.RoundedCornerShape
23−import androidx.compose.foundation.text.BasicText
24−import androidx.compose.foundation.verticalScroll
25−import androidx.compose.runtime.Composable
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateListOf
28−import androidx.compose.runtime.mutableStateOf
29−import androidx.compose.runtime.setValue
30−import androidx.compose.ui.Modifier
31−import androidx.compose.ui.draw.clip
32−import androidx.compose.ui.graphics.Color
33−import androidx.compose.ui.text.TextStyle
34−import androidx.compose.ui.text.font.FontFamily
35−import androidx.compose.ui.unit.dp
36−import androidx.compose.ui.unit.sp
3713 import org.json.JSONObject
3814
3915 /**
40− * The plain screen of step 4 (docs/android.md#steps): what the buds report, the noise-control
41− * choices, and the log, for checkpoints A1 and A2. The real pages come in step 5.
16+ * The app's one activity. It wires the screen ([App], [Screen]) to the Rust worker ([Core]) and
17+ * the phone's Bluetooth ([Bluetooth]).
4218 *
4319 * The worker runs while the app is on screen: it starts in onStart and stops in onStop, so the
44− * control channel is free for the laptop once the app is left.
20+ * control channel is free for the laptop once the app is left (docs/android.md#bluetooth).
21+ *
22+ * `adb shell am start -n io.github.krolyxon.budctl/.MainActivity --ez demo true` shows made-up
23+ * buds instead, with no Bluetooth: for the emulator and for screenshots.
4524 */
4625 class MainActivity : ComponentActivity() {
47− private lateinit var bluetooth: Bluetooth
48−
49− private var permitted by mutableStateOf(false)
50− private var debug by mutableStateOf(false)
51− private var name by mutableStateOf<String?>(null)
52− private var status by mutableStateOf<JSONObject?>(null)
53− private var waiting by mutableStateOf(false)
54− private var problem by mutableStateOf<String?>(null)
55− private val log = mutableStateListOf<String>()
26+ private lateinit var app: App
27+ private var bluetooth: Bluetooth? = null
5628
5729 private val askPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
58− permitted = granted
30+ app.permitted = granted
5931 if (granted) startWorker()
6032 }
6133
6234 override fun onCreate(savedInstanceState: Bundle?) {
6335 super.onCreate(savedInstanceState)
64− enableEdgeToEdge()
65− bluetooth = Bluetooth(
66− this,
67− onEvent = { json -> runOnUiThread { answer(JSONObject(json)) } },
68− onLog = { line ->
69− Log.i(TAG, line)
70− runOnUiThread { addLine(line) }
36+ enableEdgeToEdge(SystemBarStyle.dark(Color.TRANSPARENT), SystemBarStyle.dark(Color.TRANSPARENT))
37+ val demo = intent.getBooleanExtra("demo", false)
38+ app = App(
39+ demo,
40+ getSharedPreferences(PREFERENCES, MODE_PRIVATE),
41+ send = { Core.command(it) },
42+ restart = {
43+ Core.stop()
44+ startWorker()
7145 },
7246 )
73− debug = getSharedPreferences(PREFERENCES, MODE_PRIVATE).getBoolean("debug", false)
74− permitted = checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
75− if (!permitted) askPermission.launch(Manifest.permission.BLUETOOTH_CONNECT)
76− setContent { Screen() }
47+ if (!demo) {
48+ bluetooth = Bluetooth(
49+ this,
50+ onEvent = { json -> runOnUiThread { app.answer(JSONObject(json)) } },
51+ onLog = { line ->
52+ Log.i(TAG, line)
53+ runOnUiThread { app.addLine(line) }
54+ },
55+ )
56+ app.permitted = checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
57+ if (!app.permitted) askPermission.launch(Manifest.permission.BLUETOOTH_CONNECT)
58+ }
59+ setContent { Screen(app) { askPermission.launch(Manifest.permission.BLUETOOTH_CONNECT) } }
7760 }
7861
7962 override fun onStart() {
8063 super.onStart()
81− if (permitted) startWorker()
64+ if (!app.demo && app.permitted) startWorker()
8265 }
8366
8467 override fun onStop() {
8568 super.onStop()
86− Core.stop()
69+ if (!app.demo) Core.stop()
8770 }
8871
8972 override fun onDestroy() {
9073 super.onDestroy()
91− bluetooth.release()
74+ bluetooth?.release()
9275 }
9376
77+ /** Starts the worker and reads the buds. */
9478 private fun startWorker() {
95− val failure = Core.start(bluetooth, IDLE_MS, debug)
96− if (failure != null) problem = failure else send(JSONObject().put("command", "read"))
97− }
98−
99− private fun send(command: JSONObject) {
100− waiting = true
101− Core.command(command.toString())?.let {
102− problem = it
103− waiting = false
104− }
105− }
106−
107− private fun noise(mode: String) = send(JSONObject().put("command", "noise").put("mode", mode))
108−
109− /** One event from the worker, on the main thread. */
110− private fun answer(event: JSONObject) {
111− when (event.getString("event")) {
112− "found" -> name = event.getString("name")
113− "lost" -> problem = event.getString("message")
114− else -> {
115− waiting = false
116− when (event.getString("event")) {
117− "read" -> {
118− status = event.getJSONObject("status")
119− problem = null
120− }
121− "noise" -> {
122− val now = JSONObject().put("mode", event.getString("mode")).put("level", event.opt("level"))
123− status = JSONObject(status?.toString() ?: "{}").put("noise_control", now)
124− problem = null
125− }
126− "failed" -> problem = event.getString("message")
127− }
128− }
79+ val bluetooth = bluetooth ?: return
80+ Core.start(bluetooth, IDLE_MS, app.debug)?.let { failure ->
81+ app.connection = Connection.Failed("failed", failure)
82+ return
12983 }
130− }
131−
132− private fun addLine(line: String) {
133− log.add(line)
134− if (log.size > MAX_LINES) log.removeRange(0, log.size - MAX_LINES)
135− }
136−
137− private fun toggleDebug() {
138− debug = !debug
139− getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit().putBoolean("debug", debug).apply()
140− Core.stop()
141− if (permitted) startWorker()
142− }
143−
144− private fun copyLog() {
145− val clipboard = getSystemService(ClipboardManager::class.java)
146− clipboard.setPrimaryClip(ClipData.newPlainText("budctl log", log.joinToString("\n")))
147− }
148−
149− @Composable
150− private fun Screen() {
151− Column(
152− Modifier
153− .fillMaxSize()
154− .background(Color.Black)
155− .safeDrawingPadding()
156− .verticalScroll(rememberScrollState())
157− .padding(16.dp),
158− verticalArrangement = Arrangement.spacedBy(14.dp),
159− ) {
160− BasicText(name ?: "budctl", style = TEXT.copy(fontSize = 26.sp))
161− if (!permitted) {
162− BasicText("budctl needs the Nearby devices permission to reach the buds.", style = TEXT)
163− Chip("Allow") { askPermission.launch(Manifest.permission.BLUETOOTH_CONNECT) }
164− return@Column
165− }
166− problem?.let { BasicText(it, style = TEXT.copy(color = RED)) }
167− if (waiting) BasicText("Working…", style = DIM)
168− status?.let { Status(it) }
169− Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
170− Chip("Off") { noise("off") }
171− Chip("Transparency") { noise("transparency") }
172− Chip("ANC") { noise("anc") }
173− }
174− Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
175− Chip("Read") { send(JSONObject().put("command", "read")) }
176− Chip(if (debug) "Debug on" else "Debug off") { toggleDebug() }
177− Chip("Copy log") { copyLog() }
178− }
179− BasicText(log.joinToString("\n"), style = DIM.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp))
180− }
181− }
182−
183− @Composable
184− private fun Status(status: JSONObject) {
185− val battery = status.optJSONObject("battery")
186− val charges = listOf("left" to "L", "right" to "R", "case" to "Case").mapNotNull { (key, label) ->
187− battery?.optJSONObject(key)?.let { charge ->
188− "$label ${charge.getInt("level")}%" + if (charge.getBoolean("charging")) " (charging)" else ""
189− }
190− }
191− val lines = mutableListOf("Battery: " + charges.joinToString(" · ").ifEmpty { "unknown" })
192− status.optJSONObject("noise_control")?.let { noise ->
193− lines += "Noise control: " + noise.getString("mode") +
194− (noise.optString("level").takeUnless { it.isEmpty() || it == "null" }?.let { " ($it)" } ?: "")
195− }
196− status.optString("eq").takeUnless { it.isEmpty() || it == "null" }?.let { lines += "EQ: $it" }
197− status.optJSONArray("switches")?.let { switches ->
198− for (i in 0 until switches.length()) {
199− val switch = switches.getJSONObject(i)
200− lines += switch.getString("label") + ": " + if (switch.getBoolean("on")) "on" else "off"
201− }
202− }
203− status.optString("codec").takeUnless { it.isEmpty() || it == "null" }?.let { lines += "Codec: $it" }
204− status.optString("firmware").takeUnless { it.isEmpty() || it == "null" }?.let { lines += "Firmware: $it" }
205− BasicText(lines.joinToString("\n"), style = TEXT)
206− }
207−
208− @Composable
209− private fun Chip(label: String, onClick: () -> Unit) {
210− BasicText(
211− label,
212− style = TEXT.copy(color = Color.Black),
213− modifier = Modifier
214− .clip(RoundedCornerShape(50))
215− .background(Color.White)
216− .clickable(onClick = onClick)
217− .padding(horizontal = 16.dp, vertical = 10.dp),
218− )
84+ app.read()
21985 }
22086
22187 private companion object {
22288 const val TAG = "budctl"
22389 const val PREFERENCES = "budctl"
90+
22491 /** How long a session stays open after the last command, as on the desktop. */
22592 const val IDLE_MS = 10_000L
226− const val MAX_LINES = 1_000
227− val TEXT = TextStyle(color = Color.White, fontSize = 15.sp)
228− val DIM = TEXT.copy(color = Color(0xFF9A9A9A))
229− val RED = Color(0xFFFF5A4F)
23093 }
23194 }
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt View
@@ -0,0 +1,128 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.layout.width
9+import androidx.compose.runtime.Composable
10+import androidx.compose.ui.Alignment
11+import androidx.compose.ui.Modifier
12+import androidx.compose.ui.unit.dp
13+
14+/** The Overview page (crates/budctl-gui/src/overview.rs): battery, noise control, the switches. */
15+@Composable
16+fun Overview(app: App, askPermission: () -> Unit) {
17+ if (!app.permitted) {
18+ Note("budctl needs the Nearby devices permission to reach the buds.")
19+ Row(Modifier.padding(horizontal = 18.dp)) { Pill("Allow", filled = true, onClick = askPermission) }
20+ return
21+ }
22+ val buds = app.buds
23+ Battery(buds)
24+ buds.noise?.let { NoiseChoice(app, it) }
25+ if (buds.switches.isNotEmpty()) Switches(app)
26+}
27+
28+@Composable
29+private fun Battery(buds: Buds) {
30+ val p = LocalLook.current.p
31+ Card {
32+ Row(Modifier.padding(horizontal = 4.dp, vertical = 20.dp)) {
33+ for ((side, charge) in listOf("left" to buds.left, "case" to buds.case, "right" to buds.right)) {
34+ Column(
35+ Modifier.weight(1f),
36+ horizontalAlignment = Alignment.CenterHorizontally,
37+ verticalArrangement = Arrangement.spacedBy(12.dp),
38+ ) {
39+ Ring(charge?.level, Modifier.padding(horizontal = 8.dp).fillMaxWidth())
40+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
41+ // Charging: the bolt, and the side in the live color.
42+ if (charge?.charging == true) Glyph(Icon.Bolt, 13.dp, p.live)
43+ Label(side, color = if (charge?.charging == true) p.live else p.grey)
44+ }
45+ }
46+ }
47+ }
48+ }
49+}
50+
51+@Composable
52+private fun NoiseChoice(app: App, now: NoiseControl) {
53+ val p = LocalLook.current.p
54+ val modes = listOf(
55+ Triple(Icon.NoiseOff, "Off", "off"),
56+ Triple(Icon.Anc, "Noise cancelling", "anc"),
57+ Triple(Icon.Transparency, "Transparency", "transparency"),
58+ )
59+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
60+ Segmented(modes.map { (icon, label, mode) -> Segment(label, now.mode == mode, icon) { app.noise(mode) } }, large = true)
61+ if (app.saving(Item.Noise)) {
62+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { Label(SAVING, color = p.live) }
63+ }
64+ // The strength, when noise cancelling is on and the buds report its level.
65+ if (now.mode == "anc" && now.level != null) {
66+ val levels = listOf("light" to "Light", "medium" to "Medium", "deep" to "Deep")
67+ Row(Modifier.padding(start = 18.dp), verticalAlignment = Alignment.CenterVertically) {
68+ Label("strength", modifier = Modifier.weight(1f))
69+ Segmented(
70+ levels.map { (level, label) -> Segment(label, now.level == level) { app.noise("anc", level) } },
71+ large = false,
72+ Modifier.width(240.dp),
73+ )
74+ }
75+ }
76+ }
77+}
78+
79+/** How the page shows the switches it knows, in this order; others come last, plain. */
80+private val SWITCHES = listOf(
81+ Triple("game", Icon.Game, "Lower latency, for games and video"),
82+ Triple("spatial", Icon.Spatial, "Wider, surround-like sound"),
83+ Triple("bass-boost", Icon.Bass, "Stronger low end"),
84+ Triple("wind", Icon.Wind, "Less wind noise outdoors"),
85+ Triple("dual", Icon.Dual, "Stay connected to two devices at once"),
86+)
87+
88+@Composable
89+private fun Switches(app: App) {
90+ val buds = app.buds
91+ val rank = { s: Switch -> SWITCHES.indexOfFirst { it.first == s.key }.let { if (it < 0) SWITCHES.size else it } }
92+ Card {
93+ for (s in buds.switches.sortedBy(rank)) {
94+ val known = SWITCHES.find { it.first == s.key }
95+ SettingRow(
96+ known?.second ?: Icon.Settings,
97+ s.label,
98+ if (app.saving(Item.Switch(s.key))) SAVING else known?.third,
99+ onClick = { app.switch(s.key, !s.on) },
100+ ) { Toggle(s.on) }
101+ if (s.key == "bass-boost") buds.bassRange?.let { BassLevel(app, it) }
102+ }
103+ }
104+}
105+
106+/** The bass level: the buds take it but do not report it, so the row shows the last level set here. */
107+@Composable
108+private fun BassLevel(app: App, range: IntRange) {
109+ val level = app.bassDraft ?: app.buds.bass
110+ SettingRow(
111+ Icon.Bass,
112+ "Bass level",
113+ if (app.saving(Item.Bass)) SAVING else "Last set here: the buds do not report it",
114+ below = {
115+ DotSlider(
116+ range.first,
117+ range.last,
118+ level ?: 0,
119+ change = app::moveBass,
120+ release = app::saveBass,
121+ modifier = Modifier.padding(start = 52.dp, top = 6.dp),
122+ )
123+ },
124+ ) { ValueChip(level?.let(::gain) ?: "not set") }
125+}
126+
127+/** A gain or level with its sign: "+3", "0", "-1". */
128+fun gain(value: Int) = if (value == 0) "0" else "%+d".format(value)
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt View
@@ -0,0 +1,199 @@
1+package io.github.krolyxon.budctl
2+
3+import androidx.compose.animation.animateColorAsState
4+import androidx.compose.animation.core.animateFloatAsState
5+import androidx.compose.animation.core.tween
6+import androidx.compose.foundation.background
7+import androidx.compose.foundation.clickable
8+import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.interaction.MutableInteractionSource
10+import androidx.compose.foundation.layout.Arrangement
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.Column
13+import androidx.compose.foundation.layout.Row
14+import androidx.compose.foundation.layout.Spacer
15+import androidx.compose.foundation.layout.fillMaxSize
16+import androidx.compose.foundation.layout.fillMaxWidth
17+import androidx.compose.foundation.layout.padding
18+import androidx.compose.foundation.layout.safeDrawingPadding
19+import androidx.compose.foundation.layout.size
20+import androidx.compose.foundation.pager.HorizontalPager
21+import androidx.compose.foundation.pager.rememberPagerState
22+import androidx.compose.foundation.rememberScrollState
23+import androidx.compose.foundation.shape.RoundedCornerShape
24+import androidx.compose.foundation.text.BasicText
25+import androidx.compose.foundation.verticalScroll
26+import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.CompositionLocalProvider
28+import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.rememberCoroutineScope
31+import androidx.compose.ui.Alignment
32+import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.graphics.graphicsLayer
35+import androidx.compose.ui.platform.LocalContext
36+import androidx.compose.ui.text.TextStyle
37+import androidx.compose.ui.text.font.FontWeight
38+import androidx.compose.ui.unit.dp
39+import androidx.compose.ui.unit.sp
40+import androidx.compose.ui.window.Dialog
41+import kotlin.math.abs
42+import kotlinx.coroutines.launch
43+
44+/**
45+ * The whole screen: the buds and how the app stands with them, the pages as dot-matrix words, and
46+ * the open page under them. Swiping changes the page; the pages fade into each other rather than
47+ * slide, as Nothing's motion asks.
48+ */
49+@Composable
50+fun Screen(app: App, askPermission: () -> Unit) {
51+ val context = LocalContext.current
52+ val fonts = remember { Fonts(context.assets) }
53+ val palette = remember(app.materialYou) { if (app.materialYou) materialYou(context) else BUILT_IN }
54+ val look = Look(palette, fonts, app.motion)
55+ CompositionLocalProvider(LocalLook provides look) {
56+ val pages = Page.entries
57+ val pager = rememberPagerState { pages.size }
58+ val scope = rememberCoroutineScope()
59+ Column(
60+ Modifier
61+ .fillMaxSize()
62+ .background(palette.bg)
63+ .safeDrawingPadding(),
64+ ) {
65+ Header(app)
66+ PageWords(pages, pager.currentPage) { page ->
67+ scope.launch {
68+ if (look.motion) pager.animateScrollToPage(page, animationSpec = tween(Motion.PAGE, easing = Motion.EASE))
69+ else pager.scrollToPage(page)
70+ }
71+ }
72+ HorizontalPager(pager, Modifier.weight(1f)) { index ->
73+ Box(
74+ Modifier
75+ .fillMaxSize()
76+ .graphicsLayer {
77+ // The page stays where it is and fades, however far the swipe took it.
78+ val offset = (pager.currentPage - index) + pager.currentPageOffsetFraction
79+ translationX = offset * size.width
80+ alpha = 1f - abs(offset).coerceIn(0f, 1f)
81+ },
82+ ) {
83+ Column(
84+ Modifier
85+ .fillMaxSize()
86+ .verticalScroll(rememberScrollState())
87+ .padding(horizontal = 14.dp, vertical = 10.dp),
88+ verticalArrangement = Arrangement.spacedBy(18.dp),
89+ ) {
90+ Caption(pages[index].caption)
91+ app.problem?.let { Problem(it) { app.read() } }
92+ when (pages[index]) {
93+ Page.Overview -> Overview(app, askPermission)
94+ Page.App -> AppPage(app)
95+ }
96+ Spacer(Modifier.size(12.dp))
97+ }
98+ }
99+ }
100+ }
101+ if (app.confirmDual) {
102+ Confirm(
103+ "Turn dual connection off?",
104+ "If two devices are connected to your buds, they may drop one of them.",
105+ "Turn off",
106+ app::answerDual,
107+ )
108+ }
109+ }
110+}
111+
112+/** The buds' name, how the app stands with them, and a way to read them again. */
113+@Composable
114+private fun Header(app: App) {
115+ val p = LocalLook.current.p
116+ val connection = app.connection
117+ val (light, state) = when {
118+ connection == Connection.Demo || connection == Connection.Live -> p.fill to "connected"
119+ connection == Connection.Connecting -> p.live to "connecting…"
120+ connection is Connection.Failed && connection.kind == "busy" -> p.err to "busy"
121+ !app.buds.connected -> p.mute to "not connected"
122+ else -> p.err to "error"
123+ }
124+ Column(Modifier.padding(start = 26.dp, end = 18.dp, top = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
125+ Row(verticalAlignment = Alignment.CenterVertically) {
126+ Words(app.buds.name, 19.sp, weight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
127+ if (app.demo) Tag("demo")
128+ }
129+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
130+ Light(light, 8.dp, blinking = connection == Connection.Connecting)
131+ Label(state, modifier = Modifier.weight(1f))
132+ if (app.permitted) Pill("Refresh") { app.read() }
133+ }
134+ }
135+}
136+
137+/** The pages, as on the desktop's sidebar: the open one large with the live dot, the rest dim. */
138+@Composable
139+private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) {
140+ val look = LocalLook.current
141+ Row(
142+ Modifier
143+ .fillMaxWidth()
144+ .horizontalScroll(rememberScrollState())
145+ .padding(horizontal = 12.dp, vertical = 6.dp),
146+ verticalAlignment = Alignment.CenterVertically,
147+ ) {
148+ pages.forEachIndexed { index, page ->
149+ val isOpen = index == open
150+ val size by animateFloatAsState(if (isOpen) 38f else 26f, Motion.eased(Motion.PAGE, look.motion), label = "word")
151+ val color by animateColorAsState(
152+ if (isOpen) look.p.fg else look.p.mute,
153+ Motion.eased(Motion.PAGE, look.motion),
154+ label = "word",
155+ )
156+ Row(
157+ Modifier
158+ .clip(RoundedCornerShape(20.dp))
159+ .clickable(remember { MutableInteractionSource() }, indication = null) { onOpen(index) }
160+ .padding(horizontal = 8.dp, vertical = 4.dp),
161+ verticalAlignment = Alignment.CenterVertically,
162+ horizontalArrangement = Arrangement.spacedBy(10.dp),
163+ ) {
164+ if (isOpen) Light(look.p.live, 11.dp)
165+ // Doto is never set under 26 sp.
166+ BasicText(page.word.uppercase(), style = TextStyle(fontFamily = look.fonts.dot, fontSize = size.sp, color = color))
167+ }
168+ }
169+ }
170+}
171+
172+/** A question over the dimmed page: Cancel, or [action]. */
173+@Composable
174+private fun Confirm(heading: String, words: String, action: String, answer: (Boolean) -> Unit) {
175+ val look = LocalLook.current
176+ Dialog(onDismissRequest = { answer(false) }) {
177+ CompositionLocalProvider(LocalLook provides look) {
178+ Column(
179+ Modifier
180+ .clip(RoundedCornerShape(28.dp))
181+ .background(look.p.s1)
182+ .padding(24.dp),
183+ verticalArrangement = Arrangement.spacedBy(10.dp),
184+ ) {
185+ Words(heading, 22.sp, weight = FontWeight.SemiBold)
186+ Words(words, 13.5.sp, look.p.fgDim)
187+ Row(
188+ Modifier
189+ .fillMaxWidth()
190+ .padding(top = 16.dp),
191+ horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
192+ ) {
193+ Pill("Cancel") { answer(false) }
194+ Pill(action, filled = true) { answer(true) }
195+ }
196+ }
197+ }
198+ }
199+}
A android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt View
@@ -0,0 +1,233 @@
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.interaction.collectIsPressedAsState
8+import androidx.compose.foundation.layout.Arrangement
9+import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.ColumnScope
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.shape.CircleShape
17+import androidx.compose.foundation.shape.RoundedCornerShape
18+import androidx.compose.foundation.text.BasicText
19+import androidx.compose.runtime.Composable
20+import androidx.compose.runtime.getValue
21+import androidx.compose.runtime.remember
22+import androidx.compose.ui.Alignment
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.draw.clip
25+import androidx.compose.ui.graphics.Color
26+import androidx.compose.ui.text.TextStyle
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.style.TextAlign
29+import androidx.compose.ui.unit.TextUnit
30+import androidx.compose.ui.unit.dp
31+import androidx.compose.ui.unit.sp
32+
33+/** Round corners: cards, and the rows in them. */
34+private val CARD = RoundedCornerShape(28.dp)
35+
36+/** Fully round: pills, chips, tracks. */
37+private val ROUND = RoundedCornerShape(percent = 50)
38+
39+/** Shown instead of a row's subtitle while a write to it is on its way. */
40+const val SAVING = "Saving…"
41+
42+/** A caps label in the monospace font, like "LEFT" or "BUILT IN". */
43+@Composable
44+fun Label(words: String, size: TextUnit = 11.sp, color: Color = LocalLook.current.p.grey, modifier: Modifier = Modifier) {
45+ val look = LocalLook.current
46+ BasicText(
47+ words.uppercase(),
48+ modifier,
49+ style = TextStyle(fontFamily = look.fonts.mono, fontSize = size, color = color, lineHeight = size * 1.35f),
50+ )
51+}
52+
53+/** Plain text in Space Grotesk. */
54+@Composable
55+fun Words(
56+ words: String,
57+ size: TextUnit = 14.sp,
58+ color: Color = LocalLook.current.p.fg,
59+ weight: FontWeight = FontWeight.Normal,
60+ modifier: Modifier = Modifier,
61+) {
62+ val look = LocalLook.current
63+ BasicText(words, modifier, style = TextStyle(fontFamily = look.fonts.ui, fontSize = size, color = color, fontWeight = weight))
64+}
65+
66+/** A page's caption, at its top: "overview · battery, noise control, sound". */
67+@Composable
68+fun Caption(words: String) = Label(words, 12.sp, modifier = Modifier.padding(horizontal = 18.dp))
69+
70+/** A heading above a card. */
71+@Composable
72+fun Heading(words: String) = Label(words, 11.sp, modifier = Modifier.padding(horizontal = 18.dp))
73+
74+/** Plain words under or beside a card. */
75+@Composable
76+fun Note(words: String, modifier: Modifier = Modifier) =
77+ Words(words, 13.5.sp, LocalLook.current.p.fgDim, modifier = modifier.padding(horizontal = 18.dp))
78+
79+/** A round card holding rows. */
80+@Composable
81+fun Card(content: @Composable ColumnScope.() -> Unit) {
82+ Column(
83+ Modifier
84+ .fillMaxWidth()
85+ .clip(CARD)
86+ .background(LocalLook.current.p.s1)
87+ .padding(6.dp),
88+ verticalArrangement = Arrangement.spacedBy(2.dp),
89+ content = content,
90+ )
91+}
92+
93+/** A surface that a touch darkens, fading, the way a hover does on the desktop. */
94+@Composable
95+private fun pressable(rest: Color, onClick: (() -> Unit)?, shape: androidx.compose.ui.graphics.Shape): Modifier {
96+ val look = LocalLook.current
97+ val touches = remember { MutableInteractionSource() }
98+ val pressed by touches.collectIsPressedAsState()
99+ val color by animateColorAsState(
100+ if (pressed && onClick != null) look.p.s3 else rest,
101+ Motion.eased(Motion.HOVER, look.motion),
102+ label = "press",
103+ )
104+ val clicks = if (onClick != null) Modifier.clickable(touches, indication = null, onClick = onClick) else Modifier
105+ return Modifier.clip(shape).background(color).then(clicks)
106+}
107+
108+/** [content] centered in a circle [size] across. */
109+@Composable
110+fun Disc(color: Color, size: androidx.compose.ui.unit.Dp = 44.dp, content: @Composable () -> Unit) {
111+ val look = LocalLook.current
112+ val fill by animateColorAsState(color, Motion.eased(Motion.STATE, look.motion), label = "disc")
113+ Box(Modifier.size(size).clip(CircleShape).background(fill), contentAlignment = Alignment.Center) { content() }
114+}
115+
116+/**
117+ * A setting row: the icon in a circle, a title, an optional subtitle, and something on the right.
118+ * With [onClick], the whole row takes the touch. [current], like the preset in use, fills the
119+ * circle. A subtitle ending in "…" ("Saving…") says something is under way, in the live color.
120+ * [below] goes under the row, full width, like a slider.
121+ */
122+@Composable
123+fun SettingRow(
124+ icon: Icon,
125+ title: String,
126+ subtitle: String? = null,
127+ current: Boolean = false,
128+ onClick: (() -> Unit)? = null,
129+ below: (@Composable () -> Unit)? = null,
130+ trailing: @Composable () -> Unit = {},
131+) {
132+ val p = LocalLook.current.p
133+ Column(pressable(Color.Transparent, onClick, CARD).fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp)) {
134+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
135+ Disc(if (current) p.fill else p.s2) { Glyph(icon, 20.dp, if (current) p.fillFg else p.fg) }
136+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
137+ Words(title, 15.5.sp, weight = FontWeight.Medium)
138+ subtitle?.let { Label(it, 10.5.sp, if (it.endsWith('…')) p.live else p.grey) }
139+ }
140+ trailing()
141+ }
142+ below?.invoke()
143+ }
144+}
145+
146+/** A chevron: the row opens a page. */
147+@Composable
148+fun Chevron() = Glyph(Icon.Chevron, 18.dp, LocalLook.current.p.grey)
149+
150+/** One choice of a [Segmented] control. */
151+class Segment(val label: String, val selected: Boolean, val icon: Icon? = null, val onClick: () -> Unit)
152+
153+/** A one-of-n pill track: the chosen cell is filled. [large] puts an icon above each label. */
154+@Composable
155+fun Segmented(segments: List<Segment>, large: Boolean, modifier: Modifier = Modifier) {
156+ val look = LocalLook.current
157+ val p = look.p
158+ Row(
159+ modifier.fillMaxWidth().clip(ROUND).background(p.s2).padding(6.dp),
160+ horizontalArrangement = Arrangement.spacedBy(4.dp),
161+ ) {
162+ for (segment in segments) {
163+ val chosen by animateColorAsState(
164+ if (segment.selected) p.fill else Color.Transparent,
165+ Motion.eased(Motion.STATE, look.motion),
166+ label = "segment",
167+ )
168+ val ink = if (segment.selected) p.fillFg else p.grey
169+ Column(
170+ pressable(chosen, segment.onClick, ROUND)
171+ .weight(1f)
172+ .padding(horizontal = 8.dp, vertical = if (large) 14.dp else 10.dp),
173+ horizontalAlignment = Alignment.CenterHorizontally,
174+ verticalArrangement = Arrangement.spacedBy(8.dp),
175+ ) {
176+ if (large && segment.icon != null) Glyph(segment.icon, 22.dp, if (segment.selected) p.fillFg else p.fg)
177+ val size = if (large) 11.5.sp else 11.sp
178+ BasicText(
179+ segment.label.uppercase(),
180+ style = TextStyle(fontFamily = look.fonts.mono, fontSize = size, color = ink, textAlign = TextAlign.Center),
181+ )
182+ }
183+ }
184+ }
185+}
186+
187+/** A chip: a caps label in a pill, [filled] for the main action, else on a dark pill. */
188+@Composable
189+fun Pill(words: String, filled: Boolean = false, onClick: () -> Unit) {
190+ val p = LocalLook.current.p
191+ Box(pressable(if (filled) p.fill else p.s3, onClick, ROUND).padding(horizontal = 18.dp, vertical = 10.dp)) {
192+ Label(words, 11.sp, if (filled) p.fillFg else p.fg)
193+ }
194+}
195+
196+/** A value shown in a dark pill, like a firmware version or "NOT SET". */
197+@Composable
198+fun ValueChip(words: String) {
199+ val p = LocalLook.current.p
200+ Box(Modifier.clip(ROUND).background(p.s2).padding(horizontal = 14.dp, vertical = 8.dp)) {
201+ Label(words, 11.sp, p.fg)
202+ }
203+}
204+
205+/** A small caps tag with a live dot, like "DEMO". */
206+@Composable
207+fun Tag(words: String) {
208+ val p = LocalLook.current.p
209+ Row(
210+ Modifier.clip(ROUND).background(p.s2).padding(horizontal = 12.dp, vertical = 6.dp),
211+ verticalAlignment = Alignment.CenterVertically,
212+ horizontalArrangement = Arrangement.spacedBy(8.dp),
213+ ) {
214+ Label(words, 10.5.sp, p.fg)
215+ Light(p.live, 7.dp)
216+ }
217+}
218+
219+/** What went wrong, above a page, with a way to try again. */
220+@Composable
221+fun Problem(message: String, retry: () -> Unit) {
222+ val p = LocalLook.current.p
223+ Card {
224+ Row(
225+ Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
226+ verticalAlignment = Alignment.CenterVertically,
227+ horizontalArrangement = Arrangement.spacedBy(14.dp),
228+ ) {
229+ Words(message, 13.5.sp, p.err, modifier = Modifier.weight(1f))
230+ Pill("Try again", filled = true, onClick = retry)
231+ }
232+ }
233+}
M docs/android.md View
@@ -134,11 +134,18 @@ Written from Android's API reference. No other app's code is read for it (rule 2
134134 5. **The pages**, built the way the desktop app was: first the look with demo buds, then live
135135 buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings).
136136 - The look is the desktop app's: Nothing OS shapes and type in Material You colors. The colors
137− come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles.
138− - The same fonts, from `crates/budctl-gui/fonts`. Battery rings, dotted sliders and pill
139− switches are drawn on Compose canvases, with `motion.rs`'s timings.
140− - Touch only: no vim keys.
141− - Claude checks the look on the emulator, with the demo buds, from adb screenshots.
137+ come from Android's dynamic color (the phone's wallpaper palette), mapped to the same roles;
138+ a switch on the APP page puts the built-in near-black palette back.
139+ - The same fonts, from `crates/budctl-gui/fonts` (Gradle adds that folder to the assets). Icons,
140+ battery rings, dotted sliders and pill switches are drawn on Compose canvases, with
141+ `motion.rs`'s timings.
142+ - The desktop's sidebar becomes a row of dot-matrix page words above the page. A swipe changes
143+ the page, and the pages fade into each other rather than slide.
144+ - Touch only: no vim keys. The APP page keeps the debug switch and the log, with a copy
145+ button.
146+ - Claude checks the look on the emulator with the demo buds, from adb screenshots: launch with
147+ `--ez demo true`.
148+ - Built so far: the look, the Overview and the APP page. Next: Equalizer, Gestures, Earbuds.
142149 *Done when* **A3** passes for every page.
143150 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and
144151 tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key