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.
+1678 −185
12 files changed
- M android/app/build.gradle.kts +2 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/App.kt +227 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/AppPage.kt +75 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Buds.kt +206 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Dots.kt +214 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Icons.kt +182 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Look.kt +157 −0
- M android/app/src/main/kotlin/io/github/krolyxon/budctl/MainActivity.kt +43 −180
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Overview.kt +128 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Screen.kt +199 −0
- A android/app/src/main/kotlin/io/github/krolyxon/budctl/Ui.kt +233 −0
- M docs/android.md +12 −5
| @@ -91,6 +91,8 @@ androidComponents { | |||
| 91 | 91 | doFirst { environment("ANDROID_NDK_HOME", ndk.get().asFile.path) } | |
| 92 | 92 | } | |
| 93 | 93 | 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) | |
| 94 | 96 | ||
| 95 | 97 | val check = tasks.register<CheckNoInternet>("check${name}NoInternet") { | |
| 96 | 98 | manifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST)) | |
| @@ -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 | + | } | |
| @@ -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 | |
| @@ -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 | + | ) | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -1,231 +1,94 @@ | |||
| 1 | 1 | package io.github.krolyxon.budctl | |
| 2 | 2 | ||
| 3 | 3 | import android.Manifest | |
| 4 | − | import android.content.ClipData | |
| 5 | − | import android.content.ClipboardManager | |
| 6 | 4 | import android.content.pm.PackageManager | |
| 5 | + | import android.graphics.Color | |
| 7 | 6 | import android.os.Bundle | |
| 8 | 7 | import android.util.Log | |
| 9 | 8 | import androidx.activity.ComponentActivity | |
| 9 | + | import androidx.activity.SystemBarStyle | |
| 10 | 10 | import androidx.activity.compose.setContent | |
| 11 | 11 | import androidx.activity.enableEdgeToEdge | |
| 12 | 12 | 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 | |
| 37 | 13 | import org.json.JSONObject | |
| 38 | 14 | ||
| 39 | 15 | /** | |
| 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]). | |
| 42 | 18 | * | |
| 43 | 19 | * 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. | |
| 45 | 24 | */ | |
| 46 | 25 | 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 | |
| 56 | 28 | ||
| 57 | 29 | private val askPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> | |
| 58 | − | permitted = granted | |
| 30 | + | app.permitted = granted | |
| 59 | 31 | if (granted) startWorker() | |
| 60 | 32 | } | |
| 61 | 33 | ||
| 62 | 34 | override fun onCreate(savedInstanceState: Bundle?) { | |
| 63 | 35 | 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() | |
| 71 | 45 | }, | |
| 72 | 46 | ) | |
| 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) } } | |
| 77 | 60 | } | |
| 78 | 61 | ||
| 79 | 62 | override fun onStart() { | |
| 80 | 63 | super.onStart() | |
| 81 | − | if (permitted) startWorker() | |
| 64 | + | if (!app.demo && app.permitted) startWorker() | |
| 82 | 65 | } | |
| 83 | 66 | ||
| 84 | 67 | override fun onStop() { | |
| 85 | 68 | super.onStop() | |
| 86 | − | Core.stop() | |
| 69 | + | if (!app.demo) Core.stop() | |
| 87 | 70 | } | |
| 88 | 71 | ||
| 89 | 72 | override fun onDestroy() { | |
| 90 | 73 | super.onDestroy() | |
| 91 | − | bluetooth.release() | |
| 74 | + | bluetooth?.release() | |
| 92 | 75 | } | |
| 93 | 76 | ||
| 77 | + | /** Starts the worker and reads the buds. */ | |
| 94 | 78 | 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 | |
| 129 | 83 | } | |
| 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() | |
| 219 | 85 | } | |
| 220 | 86 | ||
| 221 | 87 | private companion object { | |
| 222 | 88 | const val TAG = "budctl" | |
| 223 | 89 | const val PREFERENCES = "budctl" | |
| 90 | + | ||
| 224 | 91 | /** How long a session stays open after the last command, as on the desktop. */ | |
| 225 | 92 | 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) | |
| 230 | 93 | } | |
| 231 | 94 | } | |
| @@ -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) | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -134,11 +134,18 @@ Written from Android's API reference. No other app's code is read for it (rule 2 | |||
| 134 | 134 | 5. **The pages**, built the way the desktop app was: first the look with demo buds, then live | |
| 135 | 135 | buds, then one page at a time (Overview, Equalizer, Gestures, Earbuds, Settings). | |
| 136 | 136 | - 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. | |
| 142 | 149 | *Done when* **A3** passes for every page. | |
| 143 | 150 | 6. **Release.** One version for everything: Gradle reads it from `Cargo.toml`. CI builds and | |
| 144 | 151 | tests the app on every push, and a tag adds a signed APK to the GitHub release. The signing key | |