From 60426ce45763d0a4a5572db6926e8e677cbc0dc0 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Fri, 13 Nov 2026 09:36:32 -0800 Subject: Plain UI copy; smart cube pairing; stackmat input - Remove marquee, snow, pointer-following, hostname line and penguin speech; the penguin keeps to its corner. UI text is plain labels. - Smart cube: scan / pair / connect / forget in settings, with live battery, move count and an unfolded view of the cube's state, and "mark solved" to resync. Paired cubes go in the synced device list. CubeBackend abstracts the platform; Linux (BlueZ) only for now. - Stackmat: audio decoder for the timer's 1200-baud data port (edge based, so AC coupling and inverted inputs work; 5- and 6-digit packets, checksum verified) and a session that records each solve once. Settings pick the input and show signal state; when enabled the mat drives the timer display and lamps instead of the keyboard. Tested against synthetic audio only. Co-Authored-By: Claude Fable 5.2 --- .../kotlin/coffee/liz/rookery/bt/SmartCube.kt | 9 ++ .../kotlin/coffee/liz/rookery/stackmat/Stackmat.kt | 120 ++++++++++++++++++++ .../kotlin/coffee/liz/rookery/store/Repo.kt | 6 + .../kotlin/coffee/liz/rookery/StackmatTest.kt | 83 ++++++++++++++ .../main/kotlin/coffee/liz/rookery/desktop/Main.kt | 43 ++++++++ .../kotlin/coffee/liz/rookery/desktop/Shots.kt | 22 +++- .../commonMain/kotlin/coffee/liz/rookery/ui/App.kt | 5 +- .../kotlin/coffee/liz/rookery/ui/AppModel.kt | 13 +++ .../kotlin/coffee/liz/rookery/ui/BldScreen.kt | 84 +++++++------- .../kotlin/coffee/liz/rookery/ui/Devices.kt | 122 +++++++++++++++++++++ .../kotlin/coffee/liz/rookery/ui/OtherScreens.kt | 122 +++++++++++++++------ .../kotlin/coffee/liz/rookery/ui/PairsScreen.kt | 33 +++--- .../kotlin/coffee/liz/rookery/ui/TimerScreen.kt | 56 ++++------ 13 files changed, 582 insertions(+), 136 deletions(-) create mode 100644 core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt create mode 100644 core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt create mode 100644 ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt index f0e9cee..96e45e7 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt @@ -49,6 +49,15 @@ sealed interface SmartCubeEvent { } /** A connected smart cube. [events] is hot: collect it before issuing requests to see their replies. */ +/** What a platform offers for finding and connecting smart cubes. */ +interface CubeBackend { + /** Nearby smart cubes, as they're heard. Collect to scan; cancel to stop. */ + fun scan(): Flow + + /** [mac] is the cube's real MAC (needed for decryption) where the platform's [address] is something else. */ + suspend fun connect(scope: kotlinx.coroutines.CoroutineScope, address: String, mac: String): SmartCube +} + interface SmartCube { val name: String? val address: String diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt new file mode 100644 index 0000000..0507fa8 --- /dev/null +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/stackmat/Stackmat.kt @@ -0,0 +1,120 @@ +package coffee.liz.rookery.stackmat + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * One status packet from a speed-stacks timer. + * [status]: ' ' running, 'S' stopped, 'I' reset, 'A' both hands down and ready, 'L'/'R' one hand, 'C' both hands. + */ +data class StackmatPacket(val status: Char, val ms: Long, val digits: Int) { + val running: Boolean get() = status == ' ' + val handsDown: Boolean get() = status == 'L' || status == 'R' || status == 'C' || status == 'A' + val ready: Boolean get() = status == 'A' +} + +/** + * Decodes the timer's data port from audio samples: 1200 baud serial (start bit, 8 data bits LSB + * first, stop bit), packets of status + 5 or 6 digits + checksum (64 + digit sum) + line ending. + * + * Sound cards are AC-coupled and may invert the signal, so levels are tracked by edges rather than + * by sign, and whichever level the line rests at between packets is taken as logical 1. + */ +class StackmatDecoder(sampleRate: Int, private val onPacket: (StackmatPacket) -> Unit) { + private val spb = sampleRate / 1200.0 + private val w = max(1, (spb / 3).toInt()) + private val ring = FloatArray(w) + private var ringAt = 0 + private var peak = 0f + private var level = false + private var run = 0L + + private var idle: Boolean? = null + private val bits = ArrayList(32) + private val tail = ArrayList(12) + + /** Size of recent signal edges, 0..~2: for a level meter. */ + val signal: Float get() = peak + + fun feed(samples: FloatArray, count: Int = samples.size) { + for (i in 0 until count) { + val x = samples[i] + val d = x - ring[ringAt] + ring[ringAt] = x; ringAt = (ringAt + 1) % w + peak = max(peak * 0.99995f, abs(d)) + run++ + if (peak > 0.02f) { + val next = if (d > 0.4f * peak) true else if (d < -0.4f * peak) false else level + if (next != level) { runEnded(level, run); level = next; run = 0 } + } + // the last byte of a packet has no following edge to end it: time it out + if (run == (12 * spb).toLong()) runEnded(level, run, stillGoing = true) + } + } + + private fun runEnded(lvl: Boolean, samples: Long, stillGoing: Boolean = false) { + val n = (samples / spb).roundToInt() + if (n >= 12) { + // a long rest: this is the idle level; finish any byte in flight with stop bits + if (idle == lvl && bits.isNotEmpty()) { repeat(10) { bits += lvl }; drain() } + idle = lvl; bits.clear() + return + } + if (stillGoing) return + val idleLevel = idle ?: return + if (n == 0 || (bits.isEmpty() && lvl == idleLevel)) return + repeat(n) { bits += lvl } + drain() + } + + private fun drain() { + val idleLevel = idle ?: return + while (bits.size >= 10) { + if (bits[0] == idleLevel || bits[9] != idleLevel) { bits.clear(); idle = null; return } // framing lost: wait for the next rest + var byte = 0 + for (k in 0 until 8) if (bits[1 + k] == idleLevel) byte = byte or (1 shl k) + repeat(10) { bits.removeAt(0) } + while (bits.isNotEmpty() && bits[0] == idleLevel) bits.removeAt(0) + byte(byte) + } + } + + private fun byte(b: Int) { + tail += b + if (tail.size > 8) tail.removeAt(0) + // does the tail end in status, digits, checksum? + for (digits in intArrayOf(6, 5)) { + if (tail.size < digits + 2) continue + val at = tail.size - digits - 2 + val status = tail[at].toChar() + if (status !in " SILRCA") continue + val ds = tail.subList(at + 1, at + 1 + digits) + if (ds.any { it !in '0'.code..'9'.code }) continue + if (64 + ds.sumOf { it - '0'.code } != tail.last()) continue + val v = ds.map { it - '0'.code } + val ms = if (digits == 6) v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + v[3] * 100 + v[4] * 10 + v[5] + else v[0] * 60_000L + (v[1] * 10 + v[2]) * 1000L + (v[3] * 10 + v[4]) * 10L + tail.clear() + onPacket(StackmatPacket(status, ms, digits)) + return + } + } +} + +/** + * Turns the packet stream into solves: a result is taken when the timer shows a stopped, non-zero + * time after having been reset, so a packet lost at the moment of stopping doesn't lose the solve + * and a time left on the display isn't recorded twice. + */ +class StackmatSession(private val onSolve: (ms: Long) -> Unit) { + var last: StackmatPacket? = null + private set + private var armed = false + + fun packet(p: StackmatPacket) { + last = p + if (p.ms == 0L || p.running) armed = true + else if (p.status == 'S' && armed) { armed = false; onSolve(p.ms) } + } +} diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt index 3e04f11..a7a0994 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt @@ -194,6 +194,12 @@ class Repo(val store: Store) { r.entities(Coll.DEVICE).map { (mac, f) -> CubeDevice(mac, f.str("name") ?: mac, f.str("proto") ?: "unknown") } } + fun saveDevice(d: CubeDevice) = store.transact { + set(Coll.DEVICE, d.mac, "name", d.name); set(Coll.DEVICE, d.mac, "proto", d.protocol); set(Coll.DEVICE, d.mac, "_del", false) + } + + fun forgetDevice(mac: String) = store.transact { delete(Coll.DEVICE, mac) } + fun pref(key: String): String? = store.read { r -> r.entity(Coll.PREFS, "global")?.str(key) } fun boolPref(key: String, default: Boolean): Boolean = store.read { r -> r.entity(Coll.PREFS, "global")?.bool(key) } ?: default diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt new file mode 100644 index 0000000..f6071ff --- /dev/null +++ b/core/src/commonTest/kotlin/coffee/liz/rookery/StackmatTest.kt @@ -0,0 +1,83 @@ +package coffee.liz.rookery + +import coffee.liz.rookery.stackmat.StackmatDecoder +import coffee.liz.rookery.stackmat.StackmatPacket +import coffee.liz.rookery.stackmat.StackmatSession +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class StackmatTest { + private fun packetBytes(status: Char, digits: String): List = + listOf(status.code) + digits.map { it.code } + (64 + digits.sumOf { it - '0' }) + listOf('\n'.code, '\r'.code) + + /** Audio for a list of packets, the way a sound card hears it: optional inversion, AC coupling, noise. */ + private fun audio(packets: List>, rate: Int, invert: Boolean, noise: Float, rng: Random = Random(1)): FloatArray { + val bits = ArrayList() + repeat(40) { bits += true } + for (p in packets) { + for (b in p) { bits += false; for (k in 0 until 8) bits += (b shr k) and 1 == 1; bits += true } + repeat(30) { bits += true } // rest between packets + } + val spb = rate / 1200.0 + val n = (bits.size * spb).toInt() + val out = FloatArray(n) + var hp = 0f; var prev = 0f + val alpha = 1f - 1f / (0.004f * rate) // ~4 ms high-pass + for (i in 0 until n) { + val raw = (if (bits[(i / spb).toInt()] != invert) 0.5f else -0.5f) + hp = alpha * (hp + raw - prev); prev = raw + out[i] = hp + (rng.nextFloat() - 0.5f) * noise + } + return out + } + + private fun decode(samples: FloatArray, rate: Int, chunk: Int = 512): List { + val got = mutableListOf() + val d = StackmatDecoder(rate) { got += it } + var at = 0 + while (at < samples.size) { val n = minOf(chunk, samples.size - at); d.feed(samples.copyOfRange(at, at + n)); at += n } + return got + } + + private val sequence = listOf(packetBytes('I', "000000"), packetBytes('A', "000000"), packetBytes(' ', "001234"), packetBytes(' ', "012345"), packetBytes('S', "112345"), packetBytes('S', "112345")) + private val expected = listOf( + StackmatPacket('I', 0, 6), StackmatPacket('A', 0, 6), StackmatPacket(' ', 1_234, 6), StackmatPacket(' ', 12_345, 6), + StackmatPacket('S', 72_345, 6), StackmatPacket('S', 72_345, 6), + ) + + @Test fun decodesCleanSignalAtCommonRates() { + for (rate in listOf(44_100, 48_000, 16_000)) assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0f), rate), "at $rate") + } + + @Test fun decodesInvertedNoisySignal() { + for (rate in listOf(44_100, 48_000)) { + assertEquals(expected, decode(audio(sequence, rate, invert = true, noise = 0.08f), rate), "inverted at $rate") + assertEquals(expected, decode(audio(sequence, rate, invert = false, noise = 0.08f), rate, chunk = 97), "noisy at $rate") + } + } + + @Test fun fiveDigitTimersAndBadChecksums() { + val gen3 = packetBytes('S', "12345") // 1:23.45 + val corrupt = packetBytes('S', "112345").toMutableList().also { it[3] = '9'.code } + val got = decode(audio(listOf(gen3, corrupt, gen3), 44_100, invert = false, noise = 0f), 44_100) + assertEquals(listOf(StackmatPacket('S', 83_450, 5), StackmatPacket('S', 83_450, 5)), got) + } + + @Test fun silenceAndNoiseDecodeToNothing() { + val rng = Random(4) + assertTrue(decode(FloatArray(48_000) { (rng.nextFloat() - 0.5f) * 0.01f }, 48_000).isEmpty()) + assertTrue(decode(FloatArray(48_000) { rng.nextFloat() - 0.5f }, 48_000).isEmpty()) + } + + @Test fun sessionRecordsEachSolveOnce() { + val solves = mutableListOf() + val s = StackmatSession { solves += it } + fun p(c: Char, ms: Long) = s.packet(StackmatPacket(c, ms, 6)) + p('S', 9_990) // a time already on the display when we connect: not ours + p('I', 0); p('A', 0); p(' ', 100); p(' ', 5_000); p('S', 12_340); p('S', 12_340); p('C', 12_340) + p('I', 0); p('S', 8_000) // the running packets were lost entirely + assertEquals(listOf(12_340L, 8_000L), solves) + } +} diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt index c2f65ca..085ad9a 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt @@ -9,7 +9,13 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import coffee.liz.rookery.audio.Chime import coffee.liz.rookery.audio.ChimePlayer +import coffee.liz.rookery.bt.BlueZTransport +import coffee.liz.rookery.bt.CubeBackend +import coffee.liz.rookery.bt.GanCubeConnector +import coffee.liz.rookery.bt.JvmAes128 import coffee.liz.rookery.images.BraveImages +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter import coffee.liz.rookery.images.ImageHit import coffee.liz.rookery.model.Event import coffee.liz.rookery.scramble.TnoodleScrambler @@ -50,6 +56,9 @@ class LocalConfig { runCatching { java.nio.file.Files.setPosixFilePermissions(file, java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) } } + fun get(key: String): String? = props.getProperty(key) + fun set(key: String, value: String?) { if (value == null) props.remove(key) else props.setProperty(key, value); save() } + /** Brave Search API key: env ROOKERY_BRAVE_KEY, else `braveApiKey` in this (never synced) file. */ val braveApiKey: String? get() = System.getenv("ROOKERY_BRAVE_KEY")?.takeIf { it.isNotBlank() } ?: props.getProperty("braveApiKey")?.takeIf { it.isNotBlank() } @@ -142,6 +151,40 @@ class DesktopHost(private val config: LocalConfig) : Host { data.bytes to "webp" }.getOrNull() + /** Bluetooth goes through BlueZ, so Linux only for now. */ + override val cubes: CubeBackend? = if (!System.getProperty("os.name").lowercase().contains("linux")) null else object : CubeBackend { + private val transport by lazy { BlueZTransport() } + override fun scan() = transport.scan().filter { GanCubeConnector.isGanCube(it) } + override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = GanCubeConnector(transport, JvmAes128).connect(scope, address, mac) + } + + override val audioSampleRate: Int get() = 44_100 + private val audioFormat = javax.sound.sampled.AudioFormat(44_100f, 16, 1, true, false) + private val lineInfo = javax.sound.sampled.DataLine.Info(javax.sound.sampled.TargetDataLine::class.java, audioFormat) + + override fun audioInputs(): List = javax.sound.sampled.AudioSystem.getMixerInfo() + .filter { runCatching { javax.sound.sampled.AudioSystem.getMixer(it).isLineSupported(lineInfo) }.getOrDefault(false) }.map { it.name }.distinct() + + override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable { + val mixer = javax.sound.sampled.AudioSystem.getMixerInfo().firstOrNull { it.name == input }?.let { javax.sound.sampled.AudioSystem.getMixer(it) } + val line = try { + ((mixer?.getLine(lineInfo) ?: javax.sound.sampled.AudioSystem.getLine(lineInfo)) as javax.sound.sampled.TargetDataLine).apply { open(audioFormat, 8192); start() } + } catch (e: Exception) { error("couldn't open ${input ?: "the default input"} (${e.message})") } + val thread = Thread { + val bytes = ByteArray(2048); val samples = FloatArray(1024) + while (line.isOpen) { + val n = line.read(bytes, 0, bytes.size) + if (n <= 0) break + for (i in 0 until n / 2) samples[i] = (((bytes[2 * i + 1].toInt() shl 8) or (bytes[2 * i].toInt() and 0xFF)).toShort()) / 32768f + onSamples(samples, n / 2) + } + }.apply { isDaemon = true; name = "stackmat-audio"; start() } + return AutoCloseable { line.stop(); line.close(); thread.interrupt() } + } + + override fun localPref(key: String): String? = config.get("local.$key") + override fun setLocalPref(key: String, value: String?) = config.set("local.$key", value) + var onFullscreen: (Boolean) -> Unit = {} override fun setFullscreen(on: Boolean) = onFullscreen(on) diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt index d7621eb..6a00c24 100644 --- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt +++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt @@ -57,6 +57,24 @@ fun shots(args: List) { override suspend fun fetchBytes(url: String) = ByteArray(0) override fun shrinkImage(bytes: ByteArray): Pair? = null override fun setFullscreen(on: Boolean) {} + override val cubes = object : coffee.liz.rookery.bt.CubeBackend { + override fun scan() = kotlinx.coroutines.flow.flowOf(coffee.liz.rookery.bt.BleAdvertisement("AA:BB:CC:00:11:22", "GANicE3_1122", -58)) + override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = object : coffee.liz.rookery.bt.SmartCube { + override val name = "GANicE3_1122"; override val address = address + override val events = kotlinx.coroutines.flow.flow { + emit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87)) + emit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets("UUUUUUFFFRRRRRRRRRFFDFFDFFDDDDDDDBBBLLLLLLLLLUBBUBBUBB")) + emit(coffee.liz.rookery.bt.SmartCubeEvent.Move("F", null, 0)); kotlinx.coroutines.awaitCancellation() + } + override suspend fun requestFacelets() {}; override suspend fun requestBattery() {}; override suspend fun requestHardware() {} + override suspend fun resetToSolved() {}; override suspend fun disconnect() {} + } + } + override fun audioInputs() = listOf("USB Audio Device") + override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit) = AutoCloseable { } + override val audioSampleRate = 44_100 + override fun localPref(key: String): String? = null + override fun setLocalPref(key: String, value: String?) {} } val model = AppModel(repo, TnoodleScrambler(), host, CoroutineScope(SupervisorJob() + Dispatchers.Default)) model.selectSession("s1") @@ -78,7 +96,9 @@ fun shots(args: List) { shoot("party", settle = 1800) { model.testParty() } model.party = null for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode } - shoot("settings") { model.tab = Tab.SETTINGS } + shoot("settings") { model.tab = Tab.SETTINGS; model.cube.scan(1) } + shoot("settings-cube") { model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true) } + model.stackmat.change(false) shoot("import") { model.tab = Tab.IMPORT } model.setTheme(ThemeMode.LIGHT) shoot("timer-paper") { model.tab = Tab.TIMER } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt index e8f64a3..505a9d1 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt @@ -73,7 +73,7 @@ fun RookeryApp(m: AppModel) { m.penguinSays != null && s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD else -> Mood.FREE } - PenguinFriend(mood, m.penguinSays, m.reducedMotion) + PenguinFriend(mood, null, m.reducedMotion) m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } } } } @@ -94,7 +94,7 @@ private fun Footer(m: AppModel) { Hr() Row(Modifier.height(31.dp), verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.width(84.dp)) // the penguin's spot - if (m.tab == Tab.TIMER) RetroButton("big display", { m.changeDisplayMode(true) }, style = Rk.t.label) + if (m.tab == Tab.TIMER) RetroButton("display mode", { m.changeDisplayMode(true) }, style = Rk.t.label) } } @@ -118,6 +118,7 @@ fun handleTimerKey(m: AppModel, e: KeyEvent): Boolean { } if (e.isMetaPressed || e.isAltPressed) return false // leave Cmd-Q and friends alone val idle = m.timer is State.Idle || m.timer is State.Stopped + if (m.stackmat.enabled && e.key != Key.Escape && e.key != Key.F) return false // the mat is the timer return when { e.key == Key.Escape && down -> { if (idle && m.displayMode) m.changeDisplayMode(false) else m.cancel(); true } e.key == Key.F && down && idle -> { m.changeDisplayMode(!m.displayMode); true } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt index 4aa37d9..cbd31bd 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import coffee.liz.rookery.audio.Chime import coffee.liz.rookery.epochMillis +import coffee.liz.rookery.bt.CubeBackend import coffee.liz.rookery.images.ImageHit import coffee.liz.rookery.importer.Importer import coffee.liz.rookery.model.DNF @@ -55,6 +56,16 @@ interface Host { /** Downscale and re-encode a picture for storage; returns bytes and file extension, or null if it isn't an image. */ fun shrinkImage(bytes: ByteArray): Pair? fun setFullscreen(on: Boolean) + /** Smart-cube Bluetooth, or null where this platform has none yet. */ + val cubes: CubeBackend? + /** Audio inputs by name, for the stackmat. */ + fun audioInputs(): List + /** Start capturing mono samples from [input] (null = default). Close the result to stop. Throws if it can't open. */ + fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable + val audioSampleRate: Int + /** Settings that belong to this machine only. */ + fun localPref(key: String): String? + fun setLocalPref(key: String, value: String?) } enum class Tab(val label: String) { TIMER("timer"), BLD("blindfolded"), IMPORT("import"), SETTINGS("settings") } @@ -381,4 +392,6 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host, suspend fun freshScramble(e: Event): String = withContext(Dispatchers.Default) { scrambler.next(e) } val bld: BldModel by lazy { BldModel(repo) } + val cube = CubeModel(repo, host.cubes, scope) + val stackmat = StackmatModel(host, scope) { ms -> if (tab == Tab.TIMER) finishSolve(ms, Penalty.NONE) } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt index 99108ba..f5c9dba 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt @@ -72,7 +72,7 @@ import kotlinx.coroutines.launch import kotlin.random.Random enum class BldMode(val label: String) { - P2W("pair → word"), W2P("word → pair"), TARGETS("stickers"), MEMO("memo"), PAIRS("all the pairs") + P2W("pair → word"), W2P("word → pair"), TARGETS("targets"), MEMO("memo"), PAIRS("letter pairs") } @Composable @@ -80,11 +80,10 @@ fun BldScreen(m: AppModel) { val mode = m.bldMode Column(Modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically) { - Label("practise: ", color = Rk.c.inkDim) Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it }) Spacer(Modifier.weight(1f)) val b = m.bld - if (b.sessionTotal > 0) Label("${b.sessionRight} of ${b.sessionTotal} right so far", style = Rk.t.label.copy(fontStyle = FontStyle.Italic), color = Rk.c.inkDim) + if (b.sessionTotal > 0) Label("${b.sessionRight}/${b.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim) } Gap(6.dp) Box(Modifier.weight(1f).fillMaxWidth()) { @@ -126,7 +125,7 @@ private fun PairToWord(m: AppModel) { val bld = m.bld val pairs = m.derived.pairs.filterValues { it.primary != null } val keys = remember(pairs.keys) { pairs.keys.sorted() } - if (keys.isEmpty()) { Label("There are no pairs with words yet. Import some, or write a few under “all the pairs”."); return } + if (keys.isEmpty()) { Label("no letter pairs yet"); return } val recent = remember { mutableStateListOf() } fun newBatch(): List { @@ -169,7 +168,7 @@ private fun PairToWord(m: AppModel) { if (where != null) { val (type, a, b) = where CubeNet(bld.setup.solvedColors, 16.dp, marks = mapOf(Cube3.slotOf(type, a) to "1", Cube3.slotOf(type, b) to "2"), dim = true) - Label("as ${if (type == PieceType.CORNER) "corners" else "edges"}: ${Cube3.stickerName(type, a)}, then ${Cube3.stickerName(type, b)}", color = Rk.c.inkDim, style = Rk.t.label) + Label("${if (type == PieceType.CORNER) "corners" else "edges"}: ${Cube3.stickerName(type, a)} → ${Cube3.stickerName(type, b)}", color = Rk.c.inkDim, style = Rk.t.label) } } Column(Modifier.fillMaxWidth().weight(1f).edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { @@ -178,9 +177,9 @@ private fun PairToWord(m: AppModel) { if (img != null) Image(img, lp.primary, Modifier.fillMaxWidth().weight(1f), contentScale = ContentScale.Fit) else Spacer(Modifier.weight(1f)) BasicText(lp.primary.orEmpty(), style = Rk.t.h1.copy(fontSize = 28.sp, color = if (miss) Rk.c.bad else Rk.c.ink, textAlign = TextAlign.Center, textDecoration = if (miss) TextDecoration.LineThrough else null)) val others = lp.words - setOfNotNull(lp.primary) - if (others.isNotEmpty()) Label("or " + others.sorted().joinToString(", "), color = Rk.c.inkDim, style = Rk.t.label) + if (others.isNotEmpty()) Label(others.sorted().joinToString(", "), color = Rk.c.inkDim, style = Rk.t.label) lp.note?.let { Label(it, color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) } - Label(if (miss) "blanked on it (${i + 1} to undo)" else "press ${i + 1} if you blanked", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label) + Label(if (miss) "missed" else "${i + 1}: mark missed", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label) } } } @@ -188,8 +187,8 @@ private fun PairToWord(m: AppModel) { } Gap(6.dp) Row(verticalAlignment = Alignment.CenterVertically) { - if (revealedAfter == null) { Label("Say each word to yourself, then ", color = Rk.c.inkDim); RetroButton("show them", { reveal() }); Label(" (space).", color = Rk.c.inkDim) } - else { Label("${formatTime(revealedAfter)} for the ${batch.size}. ", color = Rk.c.inkDim); RetroButton("next three", { next() }); Label(" (space).", color = Rk.c.inkDim) } + if (revealedAfter == null) RetroButton("reveal", { reveal() }) + else { Label("${formatTime(revealedAfter)} ", style = Rk.t.mono, color = Rk.c.inkDim); RetroButton("next", { next() }) } } } } @@ -202,7 +201,7 @@ private fun WordToPair(m: AppModel) { val bld = m.bld val pairs = m.derived.pairs.filterValues { it.primary != null } val keys = remember(pairs.keys) { pairs.keys.sorted() } - if (keys.isEmpty()) { Label("There are no pairs with words yet."); return } + if (keys.isEmpty()) { Label("no letter pairs yet"); return } val scope = rememberCoroutineScope() val recent = remember { mutableStateListOf() } var current by remember { mutableStateOf(bld.pick(keys.map { "w2p:$it" }, emptyList())!!.removePrefix("w2p:")) } @@ -243,9 +242,8 @@ private fun WordToPair(m: AppModel) { ) Box(Modifier.height(40.dp), Alignment.Center) { val v = verdict - if (v == null) Label("Which two letters? Tab if it's gone.", color = Rk.c.inkDim, style = Rk.t.label) - else Label( - if (v.first) "$current. ${when (v.second) { Grade.EASY -> "quick."; Grade.GOOD -> "yes."; else -> "yes, slowly." }}" else "no: $current.", + if (v != null) Label( + current, Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (v.first) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp), ) } @@ -322,11 +320,9 @@ private fun Targets(m: AppModel) { } Column(Modifier.width(330.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - Label("drill: ", color = Rk.c.inkDim) - Tabs(listOf(false, true), find, { if (it) "where is it" else "what is it" }, { find = it }) + Tabs(listOf(false, true), find, { if (it) "find" else "name" }, { find = it }) } Row(verticalAlignment = Alignment.CenterVertically) { - Label("with: ", color = Rk.c.inkDim) for ((i, t) in PieceType.entries.withIndex()) { if (i > 0) Label(" · ", color = Rk.c.inkDim) RetroButton(t.name.lowercase() + "s", { types = if (t in types && types.size > 1) types - t else types + t }, selected = t in types) @@ -334,10 +330,10 @@ private fun Targets(m: AppModel) { } Hr() if (find) { - Label("Click the $kind sticker that belongs at") + Label(kind, color = Rk.c.inkDim) BasicText("$letter", style = Rk.t.mono.copy(fontSize = 96.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink)) } else { - Label("The ringed $kind sticker belongs at which letter?") + Label(kind, color = Rk.c.inkDim) Gap(6.dp) val focus = remember { FocusRequester() } LaunchedEffect(q) { runCatching { focus.requestFocus() } } @@ -350,12 +346,10 @@ private fun Targets(m: AppModel) { Gap() verdict?.let { ok -> Label( - if (ok) "$letter, yes. (${Cube3.stickerName(q.type, q.home)})" else "that one is $letter, ${Cube3.stickerName(q.type, q.home)}.", + "$letter ${Cube3.stickerName(q.type, q.home)}", Modifier.semantics { liveRegion = LiveRegionMode.Polite }, color = if (ok) Rk.c.good else Rk.c.bad, style = Rk.t.heading.copy(fontSize = 20.sp), ) } - Spacer(Modifier.weight(1f)) - Label("The ones you miss come back sooner.", color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic)) } } } @@ -468,12 +462,12 @@ private fun MemoTrainer(m: AppModel) { Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.weight(1f).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) { when { - state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("scrambling…", color = Rk.c.inkDim) } + state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("…", color = Rk.c.inkDim) } phase == MemoPhase.EXECUTE || phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize(), Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Penguin(PenguinAnim.SLEEP, 128.dp, m.reducedMotion, frameMs = 700) - Label(if (phase == MemoPhase.EXECUTE) "blindfold on. ${(executeLeft + 999) / 1000}" else "blindfold on.", color = Rk.c.inkDim, style = Rk.t.body.copy(fontStyle = FontStyle.Italic)) - if (phase == MemoPhase.EXECUTE) RetroButton("that's long enough", { phase = MemoPhase.RECALL }) + if (phase == MemoPhase.EXECUTE) Label("${(executeLeft + 999) / 1000}", color = Rk.c.inkDim, style = Rk.t.mono.copy(fontSize = 28.sp)) + if (phase == MemoPhase.EXECUTE) RetroButton("skip", { phase = MemoPhase.RECALL }) } } else -> { @@ -484,7 +478,7 @@ private fun MemoTrainer(m: AppModel) { if (phase == MemoPhase.MEMO) Row(verticalAlignment = Alignment.CenterVertically) { RetroButton("←", { step(-1) }, enabled = cursor > 0, description = "previous target") Label( - if (cursor == 0) " at the ${if (active == PieceType.CORNER) "corner" else "edge"} buffer " else " target $cursor of ${tv.text.length} ", + if (cursor == 0) " buffer " else " $cursor/${tv.text.length} ", style = Rk.t.mono, color = Rk.c.inkDim, ) RetroButton("→", { step(1) }, enabled = cursor < tv.text.length, description = "next target") @@ -498,8 +492,6 @@ private fun MemoTrainer(m: AppModel) { when (phase) { MemoPhase.LOADING -> {} MemoPhase.MEMO -> { - Label("Type where each piece goes. The cube turns to that sticker, ringed, so you can read the next. Arrow keys walk back through it.", color = Rk.c.inkDim) - Gap(6.dp) for (type in PieceType.entries) { val i = idx(type) Label(if (type == PieceType.CORNER) "corners" else "edges", color = if (active == type) Rk.c.accent else Rk.c.heading, style = Rk.t.heading) @@ -511,27 +503,25 @@ private fun MemoTrainer(m: AppModel) { } Gap() Row(verticalAlignment = Alignment.CenterVertically) { - Label("pause for execution: ", color = Rk.c.inkDim) - Tabs(listOf(0, 10, 20, 40), pause, { if (it == 0) "no time" else "${it}s" }, { pause = it; m.repo.setPref("bld.execPause", it.toString()) }, style = Rk.t.label) + Label("execution delay: ", color = Rk.c.inkDim) + Tabs(listOf(0, 10, 20, 40), pause, { if (it == 0) "off" else "${it}s" }, { pause = it; m.repo.setPref("bld.execPause", it.toString()) }, style = Rk.t.label) } - RetroButton("memorised. hide it", { memoMs = monoMillis() - memoStart; phase = if (pause > 0) MemoPhase.EXECUTE else MemoPhase.RECALL }) + RetroButton("done", { memoMs = monoMillis() - memoStart; phase = if (pause > 0) MemoPhase.EXECUTE else MemoPhase.RECALL }) } - MemoPhase.EXECUTE -> Label("Hold it in your head.", color = Rk.c.inkDim) + MemoPhase.EXECUTE -> {} MemoPhase.RECALL -> { - Label("Now from memory.", color = Rk.c.inkDim) - Gap(6.dp) for (type in PieceType.entries) { val i = idx(type) Label(if (type == PieceType.CORNER) "corners" else "edges", color = Rk.c.heading, style = Rk.t.heading) SecretField(recall[i], { recall[i] = it }, Modifier.fillMaxWidth().focusRequester(focus[i]), style = Rk.t.mono.copy(fontSize = 24.sp), onSubmit = { check() }, description = "${type.name.lowercase()} recall") } Gap() - RetroButton("check it", { check() }) + RetroButton("check", { check() }) } MemoPhase.RESULT -> { results?.let { MemoResults(it, setup, tracer, state) } Gap() - RetroButton("another cube", { round++ }) + RetroButton("next", { round++ }) } } } @@ -553,13 +543,13 @@ private fun marked(letters: String, mark: Int?): AnnotatedString = buildAnnotate @Composable private fun MemoResults(results: List, setup: BldSetup, tracer: Tracer, state: IntArray?) { val solved = results.all { it.recallSolves } - Heading(if (solved) "solved." else "DNF.") + Heading(if (solved) "solved" else "DNF") for (r in results) { val name = if (r.type == PieceType.CORNER) "corners" else "edges" fun letter(pos: Int) = "${setup.scheme.letter(r.type, pos)} (${Cube3.stickerName(r.type, pos)})" val tm = r.traceMistake Table(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { - Row { HeaderCell(name, 1f); HeaderCell(if (r.recallSolves) "fine" else if (tm == null) "memory" else "tracing", 1f) } + Row { HeaderCell(name, 1f); HeaderCell(if (r.recallSolves) "ok" else if (tm == null) "memory error" else "tracing error", 1f) } Row(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(4.dp)) { Label("traced ", Modifier.width(64.dp), color = Rk.c.inkDim) Label(marked(r.traced, tm?.index), style = Rk.t.mono) @@ -569,22 +559,22 @@ private fun MemoResults(results: List, setup: BldSetup, tracer: Trac Label(marked(r.recalled, r.firstDiff.takeIf { !r.recallSolves }), style = Rk.t.mono) } val verdict = when { - r.recallSolves && tm == null -> "Traced and recalled correctly." - r.recallSolves -> "What you recalled works, though the trace you typed first had a slip." - tm == null -> "The trace was right. The recall drifts from it at target ${(r.firstDiff ?: 0) + 1}." - tm.index >= r.traced.length -> "The trace stops early" + (tm.expected?.let { ": the piece in the buffer still wanted ${letter(it)}." } ?: ", with pieces still unsolved.") - tm.brokeIntoSolved -> "Target ${tm.index + 1} breaks into a piece that was already solved." - else -> "Target ${tm.index + 1} should have been ${tm.expected?.let { letter(it) }}." + r.recallSolves && tm == null -> "" + r.recallSolves -> "Recall solves; trace had an error." + tm == null -> "Recall differs from trace at target ${(r.firstDiff ?: 0) + 1}." + tm.index >= r.traced.length -> "Trace incomplete" + (tm.expected?.let { "; next target ${letter(it)}." } ?: ".") + tm.brokeIntoSolved -> "Target ${tm.index + 1} is on a solved piece." + else -> "Target ${tm.index + 1} should be ${tm.expected?.let { letter(it) }}." } Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(4.dp)) { - Label(verdict, color = if (r.recallSolves) Rk.c.ink else Rk.c.bad) - if (!r.recallSolves && r.leftWrong.isNotEmpty()) Label("Executed as recalled, this leaves ${r.leftWrong.joinToString(", ")} wrong.", color = Rk.c.inkDim, style = Rk.t.label) + if (verdict.isNotEmpty()) Label(verdict, color = if (r.recallSolves) Rk.c.ink else Rk.c.bad) + if (!r.recallSolves && r.leftWrong.isNotEmpty()) Label("unsolved: ${r.leftWrong.joinToString(", ")}", color = Rk.c.inkDim, style = Rk.t.label) for (t in r.typos) Label(t, color = Rk.c.gold, style = Rk.t.label) } } } if (state != null) Label( - if (tracer.hasParity(tracer.trace(state, PieceType.CORNER))) "Odd number of targets: this one has parity." else "No parity on this one.", - color = Rk.c.inkDim, style = Rk.t.label.copy(fontStyle = FontStyle.Italic), + if (tracer.hasParity(tracer.trace(state, PieceType.CORNER))) "parity" else "no parity", + color = Rk.c.inkDim, style = Rk.t.label, ) } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt new file mode 100644 index 0000000..a2c2857 --- /dev/null +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt @@ -0,0 +1,122 @@ +package coffee.liz.rookery.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import coffee.liz.rookery.bt.BleAdvertisement +import coffee.liz.rookery.bt.CubeBackend +import coffee.liz.rookery.bt.SmartCube +import coffee.liz.rookery.bt.SmartCubeEvent +import coffee.liz.rookery.model.CubeDevice +import coffee.liz.rookery.stackmat.StackmatDecoder +import coffee.liz.rookery.stackmat.StackmatPacket +import coffee.liz.rookery.stackmat.StackmatSession +import coffee.liz.rookery.store.Repo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +/** Scanning for, connecting to and watching one smart cube. */ +class CubeModel(private val repo: Repo, val backend: CubeBackend?, private val scope: CoroutineScope) { + val found = mutableStateMapOf() + var scanning by mutableStateOf(false); private set + var status by mutableStateOf(""); private set + var connected by mutableStateOf(null); private set + var battery by mutableStateOf(null); private set + var hardware by mutableStateOf(null); private set + /** 54 facelets, U R F D L B, as the cube reports itself. */ + var facelets by mutableStateOf(null); private set + var moves by mutableIntStateOf(0); private set + var lastMove by mutableStateOf(null); private set + private var scanJob: Job? = null + private var linkJob: Job? = null + + fun scan(seconds: Long = 15) { + val b = backend ?: return + stopScan() + found.clear(); scanning = true; status = "" + scanJob = scope.launch { + try { withTimeoutOrNull(seconds * 1000) { b.scan().collect { found[it.address] = it } } } + catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { status = e.message ?: "scan failed" } + finally { scanning = false } + } + } + + fun stopScan() { scanJob?.cancel(); scanning = false } + + fun connect(address: String, name: String?, mac: String = address) { + val b = backend ?: return + stopScan(); linkJob?.cancel() + status = "connecting" + linkJob = scope.launch { + try { + val cube = withContext(Dispatchers.Default) { b.connect(scope, address, mac) } + connected = cube; status = ""; battery = null; hardware = null; facelets = null; moves = 0; lastMove = null + repo.saveDevice(CubeDevice(mac.uppercase(), cube.name ?: name ?: mac, "gan")) + launch { delay(200); cube.requestHardware(); delay(300); cube.requestBattery(); delay(300); cube.requestFacelets() } + cube.events.collect { e -> + when (e) { + is SmartCubeEvent.Move -> { moves++; lastMove = e.move } + is SmartCubeEvent.Facelets -> facelets = e.facelets + is SmartCubeEvent.Battery -> battery = e.percent + is SmartCubeEvent.Hardware -> hardware = listOfNotNull(e.name, e.softwareVersion?.let { "sw $it" }).joinToString(" ") + is SmartCubeEvent.Gyro -> {} + SmartCubeEvent.Disconnected -> { connected = null; status = "disconnected" } + } + } + } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { + connected = null; status = e.message ?: "couldn't connect" + } + } + } + + fun disconnect() { + val cube = connected ?: return + scope.launch { runCatching { cube.disconnect() }; connected = null; linkJob?.cancel(); status = "" } + } + + /** Tell the cube its current state is solved (after slice moves have confused it). */ + fun markSolved() { val cube = connected ?: return; scope.launch { runCatching { cube.resetToSolved(); delay(200); cube.requestFacelets() } } } + + fun forget(mac: String) = repo.forgetDevice(mac) +} + +/** Listening to a stackmat on an audio input. */ +class StackmatModel(private val host: Host, private val scope: CoroutineScope, onSolve: (Long) -> Unit) { + var enabled by mutableStateOf(false); private set + var input by mutableStateOf(host.localPref("stackmat.input")); private set + var error by mutableStateOf(null); private set + var packet by mutableStateOf(null); private set + var level by mutableStateOf(0f); private set + /** True while packets are arriving. */ + var live by mutableStateOf(false); private set + private var line: AutoCloseable? = null + private var lastPacketAt = 0L + private val session = StackmatSession { ms -> scope.launch { onSolve(ms) } } + + init { if (host.localPref("stackmat.enabled") == "true") change(true) } + + fun change(on: Boolean) { + host.setLocalPref("stackmat.enabled", on.toString()) + line?.let { runCatching { it.close() } }; line = null + enabled = on; error = null; packet = null; live = false; level = 0f + if (!on) return + try { + val decoder = StackmatDecoder(host.audioSampleRate) { p -> + lastPacketAt = coffee.liz.rookery.monoMillis() + scope.launch { packet = p; live = true; session.packet(p) } + } + line = host.openAudio(input) { samples, n -> decoder.feed(samples, n); level = decoder.signal } + scope.launch { while (enabled) { delay(500); if (coffee.liz.rookery.monoMillis() - lastPacketAt > 1000) live = false } } + } catch (e: Exception) { enabled = false; error = e.message ?: "couldn't open the input" } + } + + fun chooseInput(name: String?) { input = name; host.setLocalPref("stackmat.input", name); if (enabled) change(true) } +} diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt index f467fe7..6ed1cdf 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt @@ -31,16 +31,12 @@ import kotlinx.coroutines.launch @Composable fun ImportScreen(m: AppModel) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).widthIn(max = 760.dp)) { - Heading("bring your data") - Label("Importing the same file twice is harmless; whatever is already here gets skipped.") - Gap() + Heading("import") Row(verticalAlignment = Alignment.CenterVertically) { - RetroButton("a csTimer export", { m.importCsTimer() }) - Label(" the .txt that csTimer's “export to file” gives you.", color = Rk.c.inkDim) + RetroButton("csTimer (.txt)", { m.importCsTimer() }) } Row(verticalAlignment = Alignment.CenterVertically) { - RetroButton("a Letter Pair Gym export", { m.importLetterPairGym() }) - Label(" the .zip: words, notes, pictures, history, practice stats.", color = Rk.c.inkDim) + RetroButton("Letter Pair Gym (.zip)", { m.importLetterPairGym() }) } if (m.importLog.isNotEmpty()) { Hr() @@ -60,51 +56,108 @@ fun SettingsScreen(m: AppModel) { Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) { Column(Modifier.weight(1f).widthIn(max = 560.dp)) { Heading("timer") - Checkbox("inspect first (15 seconds; +2 past 15, DNF past 17)", m.inspection, m::changeInspection) - Checkbox("make noise (pips at 8 and 12 seconds, a tune for a PB)", m.sound, m::changeSound) + Checkbox("WCA inspection", m.inspection, m::changeInspection) + Checkbox("sound", m.sound, m::changeSound) Row { - RetroButton("hear the PB tune", { m.testChime() }) + RetroButton("test PB sound", { m.testChime() }) Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) - RetroButton("see the PB party", { m.testParty() }) + RetroButton("test PB animation", { m.testParty() }) } Hr() - Heading("looks") + Heading("appearance") Row(verticalAlignment = Alignment.CenterVertically) { - Label("page: ", color = Rk.c.inkDim) - Tabs(ThemeMode.entries, m.themeMode, { when (it) { ThemeMode.SYSTEM -> "whatever the computer says"; ThemeMode.LIGHT -> "paper"; ThemeMode.DARK -> "night" } }, { m.setTheme(it) }) + Label("theme: ", color = Rk.c.inkDim) + Tabs(ThemeMode.entries, m.themeMode, { when (it) { ThemeMode.SYSTEM -> "system"; ThemeMode.LIGHT -> "light"; ThemeMode.DARK -> "dark" } }, { m.setTheme(it) }) } - Checkbox("hold everything still (no confetti, no gliding cube, a penguin that doesn't fidget)", m.reducedMotion, m::changeReducedMotion) + Checkbox("reduce motion", m.reducedMotion, m::changeReducedMotion) Hr() - Heading("syncing") - Label("Everything lives in one folder, as a log per device that only that device writes. Share the folder with Syncthing (or Drive, or a USB stick) and the devices merge on their own.") - Gap(6.dp) + Heading("sync folder") Row(verticalAlignment = Alignment.CenterVertically) { RetroField(syncPath, { syncPath = it }, Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 13.sp), description = "sync folder") Spacer(Modifier.width(6.dp)) RetroButton("browse", { scope.launch { m.host.pickFolder("Sync folder")?.let { syncPath = it } } }) - RetroButton("use it", { m.host.setSyncFolder(syncPath); moved = true }, enabled = syncPath != m.host.syncFolder) + RetroButton("apply", { m.host.setSyncFolder(syncPath); moved = true }, enabled = syncPath != m.host.syncFolder) } - if (moved) Label("Noted. Restart rookery to switch folders.", color = Rk.c.gold) + if (moved) Label("Restart to apply.", color = Rk.c.gold) Hr() - Heading("pictures") + Heading("image search") Label( - if (m.host.braveApiKey != null) "Brave image search is set up on this machine." - else "No Brave Search key on this machine. Put braveApiKey=… in ~/.config/rookery/config.properties and restart.", + if (m.host.braveApiKey != null) "Brave key found." + else "No key. Set braveApiKey in ~/.config/rookery/config.properties.", color = if (m.host.braveApiKey != null) Rk.c.ink else Rk.c.gold, ) Hr() - Heading("smart cubes") - val devices = m.repo.devices() - if (devices.isEmpty()) Label("None known yet.", color = Rk.c.inkDim) - else Table { - Row { HeaderCell("name", 2f); HeaderCell("address", 2f); HeaderCell("speaks", 1f) } - for (d in devices) Row { Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f, style = Rk.t.mono.copy(fontSize = 13.sp)); Cell(d.protocol, 1f, style = Rk.t.body) } - } + SmartCubes(m) + Hr() + Stackmat(m) } Column(Modifier.weight(1f).widthIn(max = 560.dp)) { BldSetup(m) } } } +@Composable +private fun SmartCubes(m: AppModel) { + val c = m.cube + Heading("smart cube") + if (c.backend == null) { Label("Bluetooth isn't supported on this platform yet.", color = Rk.c.inkDim); return } + val cube = c.connected + if (cube != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Label(cube.name ?: cube.address) + Label(" " + listOfNotNull(c.hardware, c.battery?.let { "battery $it%" }, "${c.moves} moves", c.lastMove).joinToString(" · "), color = Rk.c.inkDim, style = Rk.t.label) + } + c.facelets?.takeIf { it.length == 54 }?.let { f -> + Gap(4.dp) + CubeNet(IntArray(54) { "URFDLB".indexOf(f[it]).coerceAtLeast(0) }, 16.dp) + } + Row { + RetroButton("mark solved", { c.markSolved() }, description = "tell the cube it is solved now") + Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) + RetroButton("disconnect", { c.disconnect() }) + } + return + } + val known = m.repo.devices() + val knownMacs = known.map { it.mac.uppercase() }.toSet() + val nearby = c.found.values.filter { it.address.uppercase() !in knownMacs }.sortedByDescending { it.rssi ?: -999 } + if (known.isNotEmpty() || nearby.isNotEmpty()) Table { + Row { HeaderCell("name", 2f); HeaderCell("address", 2f); HeaderCell("", 2f) } + for (d in known) Row(verticalAlignment = Alignment.CenterVertically) { + Cell(d.name, 2f, style = Rk.t.body); Cell(d.mac, 2f, style = Rk.t.mono.copy(fontSize = 12.sp)) + Row(Modifier.weight(2f)) { RetroButton("connect", { c.connect(d.mac, d.name) }, style = Rk.t.label); RetroButton("forget", { c.forget(d.mac) }, style = Rk.t.label) } + } + for (a in nearby) Row(verticalAlignment = Alignment.CenterVertically) { + Cell(a.name ?: "?", 2f, style = Rk.t.body); Cell(a.address, 2f, style = Rk.t.mono.copy(fontSize = 12.sp)) + Row(Modifier.weight(2f)) { RetroButton("pair", { c.connect(a.address, a.name) }, style = Rk.t.label); a.rssi?.let { Label(" $it dBm", color = Rk.c.inkDim, style = Rk.t.label, modifier = Modifier.padding(vertical = 4.dp)) } } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (c.scanning) RetroButton("stop", { c.stopScan() }) else RetroButton("scan", { c.scan() }) + Label(" " + when { c.status.isNotEmpty() -> c.status; c.scanning -> "scanning"; else -> "" }, color = Rk.c.inkDim, style = Rk.t.label) + } +} + +@Composable +private fun Stackmat(m: AppModel) { + val s = m.stackmat + Heading("stackmat") + Checkbox("use stackmat", s.enabled, s::change) + val inputs = remember { listOf(null) + m.host.audioInputs() } + Row(verticalAlignment = Alignment.CenterVertically) { + Label("input: ", color = Rk.c.inkDim) + Tabs(inputs, s.input.takeIf { it in inputs }, { it ?: "default" }, { s.chooseInput(it) }, style = Rk.t.label) + } + s.error?.let { Label(it, color = Rk.c.bad) } + if (s.enabled) { + val p = s.packet + Label( + if (s.live && p != null) "signal ok · ${coffee.liz.rookery.model.formatTime(p.ms)} · ${when { p.running -> "running"; p.ready -> "ready"; p.handsDown -> "hands down"; p.status == 'S' -> "stopped"; else -> "reset" }}" + else "no signal · level ${(s.level * 100).toInt()}", + style = Rk.t.mono.copy(fontSize = 13.sp), color = if (s.live) Rk.c.ink else Rk.c.gold, + ) + } +} + /** How you hold the cube, where your buffers are, and what every sticker is called. */ @Composable private fun BldSetup(m: AppModel) { @@ -113,7 +166,7 @@ private fun BldSetup(m: AppModel) { var editing by remember { mutableStateOf?>(null) } Heading("blindfolded") Row(verticalAlignment = Alignment.CenterVertically) { - Label("you hold it: ", color = Rk.c.inkDim) + Label("orientation: ", color = Rk.c.inkDim) val presets = listOf("yellow up, orange front" to "x2 y'", "white up, green front" to "", "yellow up, green front" to "z2", "white up, orange front" to "y'") Tabs(presets, presets.firstOrNull { it.second == setup.rotation } ?: presets[0], { it.first }, { bld.updateSetup(setup.copy(rotation = it.second)) }, style = Rk.t.label) } @@ -123,9 +176,8 @@ private fun BldSetup(m: AppModel) { val current = options.firstOrNull { Cube3.pieceId(t, it) == Cube3.pieceId(t, setup.buffer(t)) } ?: options[0] Tabs(options, current, { Cube3.pieceName(t, it) }, { bld.updateSetup(if (t == PieceType.CORNER) setup.copy(cornerBuffer = it) else setup.copy(edgeBuffer = it)) }) } - Label("Old Pochmann corners and edges for now. M2 arrives with the solve analyser.", color = Rk.c.inkDim, style = Rk.t.label) Gap() - Label("Lettering. Click a sticker and type its new letter; the two letters trade places.", color = Rk.c.inkDim) + Label("lettering (click a sticker to change it)", color = Rk.c.inkDim) Gap(6.dp) CubeNet( setup.solvedColors, 34.dp, @@ -139,7 +191,7 @@ private fun BldSetup(m: AppModel) { val focus = remember { FocusRequester() } LaunchedEffect(editing) { runCatching { focus.requestFocus() } } Row(verticalAlignment = Alignment.CenterVertically) { - Label("${Cube3.stickerName(type, pos)} is ${setup.scheme.letter(type, pos)}. call it ") + Label("${Cube3.stickerName(type, pos)}: ") RetroField(v, { nv -> val c = nv.uppercase().filter { it.isLetter() }.take(1) v = c @@ -147,7 +199,7 @@ private fun BldSetup(m: AppModel) { }, Modifier.width(48.dp).focusRequester(focus), placeholder = "?", style = Rk.t.mono.copy(fontSize = 18.sp)) } } - RetroButton("back to plain Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) }) + RetroButton("reset to Speffz", { bld.updateSetup(setup.copy(scheme = LetterScheme())) }) } private fun target(slot: Int): Pair? = diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt index f5a1e16..dfa241c 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt @@ -65,16 +65,16 @@ fun PairsScreen(m: AppModel) { Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.weight(1f).fillMaxHeight()) { Row(verticalAlignment = Alignment.CenterVertically) { - Label("look one up: ") + Label("pair: ") RetroField(query, { query = it.take(2) }, Modifier.width(72.dp).focusRequester(focus), placeholder = "AB", style = Rk.t.mono.copy(fontSize = 22.sp), description = "type a letter pair") val n = m.derived.pairs.values.count { it.primary != null } - Label(" $n of ${LETTERS.length * (LETTERS.length - 1)} have a word. pinker means you've been missing it.", color = Rk.c.inkDim, style = Rk.t.label) + Label(" $n / ${LETTERS.length * (LETTERS.length - 1)}", color = Rk.c.inkDim, style = Rk.t.label) } Gap(6.dp) PairGrid(m.derived.pairs, shown) { selected = it; query = ""; m.clearImageSearch() } } Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { - if (shown != null) PairCard(m, shown, m.derived.pairs[shown]) else Label("Pick a cell, or type a pair.", color = Rk.c.inkDim) + if (shown != null) PairCard(m, shown, m.derived.pairs[shown]) else Label("no pair selected", color = Rk.c.inkDim) } } } @@ -141,20 +141,20 @@ private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) { } Box(Modifier.fillMaxWidth().height(230.dp).edge(p, sunk = true), Alignment.Center) { if (image != null) Image(image, lp?.primary ?: pair, Modifier.fillMaxSize().padding(4.dp), contentScale = ContentScale.Fit) - else Label("no picture", color = p.inkDim, style = Rk.t.label) + else Label("no image", color = p.inkDim, style = Rk.t.label) } Row(Modifier.fillMaxWidth().edge(p, sunk = true).padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { - Label("picture: ", color = p.inkDim) - RetroButton("find one", { finding = !finding; if (finding && query.isNotBlank()) m.searchImages(query) else m.clearImageSearch() }, selected = finding) + Label("image: ", color = p.inkDim) + RetroButton("search", { finding = !finding; if (finding && query.isNotBlank()) m.searchImages(query) else m.clearImageSearch() }, selected = finding) Label(" · ", color = p.inkDim) - RetroButton("from a file", { m.setPairImageFromFile(pair) }) - if (lp?.image != null) { Label(" · ", color = p.inkDim); RetroButton("take it off", { m.repo.setPairImage(pair, null) }) } + RetroButton("file", { m.setPairImageFromFile(pair) }) + if (lp?.image != null) { Label(" · ", color = p.inkDim); RetroButton("remove", { m.repo.setPairImage(pair, null) }) } } if (finding) Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) { - if (m.host.braveApiKey == null) Label("Image search needs a Brave Search key on this machine (see settings).", color = p.gold) + if (m.host.braveApiKey == null) Label("No Brave key (see settings).", color = p.gold) else { Row(verticalAlignment = Alignment.CenterVertically) { - RetroField(query, { query = it }, Modifier.weight(1f), placeholder = "search for…", onSubmit = { m.searchImages(query) }, description = "image search") + RetroField(query, { query = it }, Modifier.weight(1f), placeholder = "search", onSubmit = { m.searchImages(query) }, description = "image search") RetroButton("search", { m.searchImages(query) }, enabled = query.isNotBlank()) } if (m.imageStatus.isNotEmpty()) Label(m.imageStatus, color = p.inkDim, style = Rk.t.label) @@ -171,31 +171,30 @@ private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) { } } } - if (m.imageHits.isNotEmpty()) Label("click one to keep it.", color = p.inkDim, style = Rk.t.label) } } Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Label("word ", Modifier.width(48.dp), color = p.inkDim) RetroField(word, { word = it }, Modifier.weight(1f), onSubmit = { m.setPrimaryWord(pair, word) }, description = "main word for $pair") - RetroButton("keep", { m.setPrimaryWord(pair, word) }, enabled = word.isNotBlank() && word.trim() != lp?.primary) + RetroButton("save", { m.setPrimaryWord(pair, word) }, enabled = word.isNotBlank() && word.trim() != lp?.primary) } Gap(4.dp) Row(verticalAlignment = Alignment.CenterVertically) { Label("note ", Modifier.width(48.dp), color = p.inkDim) RetroField(note, { note = it }, Modifier.weight(1f), onSubmit = { m.setPairNote(pair, note) }, description = "note for $pair") - RetroButton("keep", { m.setPairNote(pair, note) }, enabled = note.trim() != lp?.note.orEmpty()) + RetroButton("save", { m.setPairNote(pair, note) }, enabled = note.trim() != lp?.note.orEmpty()) } val others = lp?.words.orEmpty().filter { it != lp?.primary }.sorted() Gap(4.dp) - Label(if (others.isEmpty()) "no spare words." else "spare words:", color = p.inkDim) + if (others.isNotEmpty()) Label("other words", color = p.inkDim) for (w in others) Row(verticalAlignment = Alignment.CenterVertically) { Label(w, Modifier.weight(1f)) - RetroButton("promote", { m.setPrimaryWord(pair, w) }, description = "make $w the main word") - RetroButton("drop", { m.removeWord(pair, w) }, description = "remove $w") + RetroButton("use", { m.setPrimaryWord(pair, w) }, description = "make $w the main word") + RetroButton("remove", { m.removeWord(pair, w) }, description = "remove $w") } Row(verticalAlignment = Alignment.CenterVertically) { - RetroField(alt, { alt = it }, Modifier.weight(1f), placeholder = "another word…", onSubmit = { m.addWord(pair, alt); alt = "" }) + RetroField(alt, { alt = it }, Modifier.weight(1f), placeholder = "add word", onSubmit = { m.addWord(pair, alt); alt = "" }) RetroButton("add", { m.addWord(pair, alt); alt = "" }, enabled = alt.isNotBlank()) } } diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt index 926fe10..ed6f403 100644 --- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt +++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt @@ -76,7 +76,6 @@ fun TimerScreen(m: AppModel) { Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { ScrambleBox(m) TimerDevice(m, Modifier.weight(1f).fillMaxWidth()) - Label(hint(m.timer, m.inspection, m.host.touchInput), Modifier.fillMaxWidth(), color = Rk.c.inkDim, style = Rk.t.label.copy(textAlign = TextAlign.Center)) LastSolveLine(m) } Column(Modifier.width(280.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -106,7 +105,7 @@ private fun SessionBox(m: AppModel) { } } } - RetroButton(if (choosing) "never mind" else "new session", { choosing = !choosing }) + RetroButton(if (choosing) "cancel" else "new session", { choosing = !choosing }) if (choosing) FlowRow { for (e in Event.entries) RetroButton(e.short, { m.createSession(e); choosing = false }, description = "new ${e.title} session", style = Rk.t.label) } @@ -121,7 +120,7 @@ private fun SolveTable(m: AppModel, modifier: Modifier) { LaunchedEffect(solves.size) { if (solves.isNotEmpty()) list.scrollToItem(0) } Table(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) { HeaderCell("#", 0.7f); HeaderCell("time", 1.4f); HeaderCell("ao5", 1.3f); HeaderCell("ao12", 1.3f) } - if (solves.isEmpty()) Label("no solves yet.", Modifier.padding(6.dp), color = Rk.c.inkDim) + if (solves.isEmpty()) Label("no solves", Modifier.padding(6.dp), color = Rk.c.inkDim) LazyColumn(state = list) { val n = solves.size itemsIndexed(solves.asReversed(), key = { _, s -> s.id }) { ri, s -> @@ -150,7 +149,7 @@ private fun ScrambleBox(m: AppModel) { Table(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically) { BasicText(m.event.title.lowercase(), Modifier.weight(1f), style = Rk.t.heading.copy(color = Rk.c.heading)) - RetroButton("next scramble", { m.newScramble() }) + RetroButton("next", { m.newScramble() }) } val size = when { m.scramble.length > 300 -> 13.sp; m.scramble.length > 120 -> 16.sp; else -> 22.sp } BasicText( @@ -179,17 +178,6 @@ private fun inspectionText(elapsed: Long): String { return if (left > 0) "$left" else if (elapsed <= 17_000) "+2" else "dnF" } -private fun hint(s: State, inspection: Boolean, touch: Boolean): String { - val key = if (touch) "the screen" else "space" - return when (s) { - State.Idle, is State.Stopped -> if (inspection) (if (touch) "tap to start inspection." else "space starts inspection. f: big display.") else "hold $key until the green light, then let go." - is State.Inspecting -> "hold $key until the green light." - is State.Holding -> "keep holding." - is State.Ready -> "let go." - is State.Running -> if (touch) "tap anywhere to stop." else "any key stops. esc throws it away." - } -} - /** * The timer as an object on the desk: a dark plastic body, a grey-green liquid-crystal window that * fills whatever room it has, and the two lamps a mat has. Red is "hands down", green is "go". @@ -198,7 +186,8 @@ private fun hint(s: State, inspection: Boolean, touch: Boolean): String { fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) { val s = m.timer val last = m.lastSolve()?.let { if (it.penalty == Penalty.DNF) "dnF" else formatTime(it.result) + if (it.penalty == Penalty.PLUS2) "+" else "" } ?: "0.00" - val text = readout(s, m.now, last) + val mat = m.stackmat.packet.takeIf { m.stackmat.enabled && m.stackmat.live } + val text = if (mat != null) formatTime(mat.ms) else readout(s, m.now, last) val glass = Color(0xFFA9B7A1) val ink = Color(0xFF1A2316) Column( @@ -214,11 +203,11 @@ fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) { SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize()) } Row(Modifier.fillMaxWidth().padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Lamp(Color(0xFFFF4D4D), lit = s is State.Holding) + Lamp(Color(0xFFFF4D4D), lit = if (mat != null) mat.handsDown && !mat.ready else s is State.Holding) Spacer(Modifier.width(8.dp)) - Lamp(Color(0xFF58E06F), lit = s is State.Ready || s is State.Running) + Lamp(Color(0xFF58E06F), lit = if (mat != null) mat.ready || mat.running else s is State.Ready || s is State.Running) Spacer(Modifier.weight(1f)) - BasicText("rookery", style = Rk.t.label.copy(color = Color(0xFF6A6F6C), letterSpacing = 3.sp)) + BasicText(if (m.stackmat.enabled) (if (mat != null) "stackmat" else "stackmat: no signal") else "rookery", style = Rk.t.label.copy(color = Color(0xFF6A6F6C), letterSpacing = 3.sp)) } } } @@ -236,12 +225,12 @@ private fun Lamp(color: Color, lit: Boolean) { private fun LastSolveLine(m: AppModel) { val last = m.lastSolve() ?: return Row(verticalAlignment = Alignment.CenterVertically) { - Label("that one was: ", color = Rk.c.inkDim) - Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), last.penalty, { when (it) { Penalty.NONE -> "fine"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(last.id, it) }) + Label("last: ", color = Rk.c.inkDim) + Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), last.penalty, { when (it) { Penalty.NONE -> "ok"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(last.id, it) }) Label(" · ", color = Rk.c.inkDim) - RetroButton("delete it", { m.deleteSolve(last.id) }, description = "delete last solve") + RetroButton("delete", { m.deleteSolve(last.id) }, description = "delete last solve") Spacer(Modifier.weight(1f)) - if (m.lastPbs.isNotEmpty()) Label("pb " + m.lastPbs.joinToString(" & ") { it.kind.label } + "!", color = Rk.c.accent, style = Rk.t.heading) + if (m.lastPbs.isNotEmpty()) Label("PB " + m.lastPbs.joinToString(", ") { it.kind.label }, color = Rk.c.accent, style = Rk.t.heading) } } @@ -250,7 +239,7 @@ private fun StatsBox(m: AppModel) { val ss = m.derived.sessionStats val es = m.derived.eventStats Table(Modifier.fillMaxWidth()) { - Row { HeaderCell("", 1f); HeaderCell("now", 1.2f); HeaderCell("best", 1.2f); HeaderCell("ever", 1.2f) } + Row { HeaderCell("", 1f); HeaderCell("current", 1.2f); HeaderCell("best", 1.2f); HeaderCell("all-time", 1.2f) } for (k in StatKind.entries) { val line = ss.line(k) val life = es.line(k) @@ -263,7 +252,7 @@ private fun StatsBox(m: AppModel) { Cell(formatTime(life?.best, round), 1.2f, color = Rk.c.gold.takeIf { life?.best != null && life.best == line?.best && life.best != DNF }) } } - Label("${ss.solved} of ${ss.count} solved, mean ${formatTime(ss.mean, true)}", Modifier.padding(horizontal = 4.dp), color = Rk.c.inkDim, style = Rk.t.label) + Label("${ss.solved}/${ss.count}, mean ${formatTime(ss.mean, true)}", Modifier.padding(horizontal = 4.dp), color = Rk.c.inkDim, style = Rk.t.label) } } @@ -276,7 +265,7 @@ private fun PreviewBox(m: AppModel) { val density = LocalDensity.current val painter = remember(svg, density) { svg?.let { runCatching { it.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() } } if (state == null && painter == null) return - GroupBox("what it should look like") { + GroupBox("scramble") { val colors = remember(m.event) { Puzzles.colors(m.event.puzzle).map(::rgb) } if (state != null) PuzzleView(state, colors, Modifier.fillMaxWidth().height(190.dp), description = "3D scramble preview, drag to spin", draggable = true) // tnoodle's official drawing: the reference for every event @@ -289,14 +278,14 @@ private fun ReplayBox(m: AppModel, r: Replay) { val camera = remember(r.solveId) { Camera() } GroupBox("replay") { PuzzleView(r.state, remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }, Modifier.fillMaxWidth().height(230.dp), camera, r.anim, "solve replay, drag to spin", draggable = true) - Label("move ${r.index} of ${r.total}, ${formatTime(r.elapsedMs)}", style = Rk.t.mono) + Label("${r.index}/${r.total} ${formatTime(r.elapsedMs)}", style = Rk.t.mono) when (r.endedSolved) { - true -> Label("and it ends solved.", color = Rk.c.good) - false -> Label("it does not end solved.", color = Rk.c.bad) + true -> Label("solved", color = Rk.c.good) + false -> Label("not solved", color = Rk.c.bad) null -> {} } Row { - m.derived.sessionSolves.firstOrNull { it.id == r.solveId }?.let { s -> RetroButton("again", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } + m.derived.sessionSolves.firstOrNull { it.id == r.solveId }?.let { s -> RetroButton("replay", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } RetroButton("close", { m.stopReplay() }) } } @@ -309,10 +298,10 @@ private fun SelectedSolveBox(m: AppModel) { Label(formatSolve(s), style = Rk.t.mono.copy(fontSize = 28.sp)) Label(s.scramble, style = Rk.t.mono.copy(fontSize = 12.sp), color = Rk.c.inkDim) if (s.comment.isNotEmpty()) Label("“${s.comment}”") - Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), s.penalty, { when (it) { Penalty.NONE -> "fine"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(s.id, it) }) + Tabs(listOf(Penalty.NONE, Penalty.PLUS2, Penalty.DNF), s.penalty, { when (it) { Penalty.NONE -> "ok"; Penalty.PLUS2 -> "+2"; Penalty.DNF -> "dnf" } }, { m.setPenalty(s.id, it) }) Row { s.moves?.let { mv -> - if (s.event.puzzle == Puzzle.CUBE3) { RetroButton("replay the ${mv.split(' ').size} moves", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } + if (s.event.puzzle == Puzzle.CUBE3) { RetroButton("replay (${mv.split(' ').size} moves)", { m.startReplay(s) }); Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp)) } } RetroButton("delete", { m.deleteSolve(s.id) }) } @@ -331,8 +320,7 @@ private fun DisplayMode(m: AppModel) { Spacer(Modifier.width(84.dp)) // the penguin's spot val parts = listOf(StatKind.AO5, StatKind.AO12, StatKind.AO100).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } } Label((listOf(m.event.short.lowercase(), "${ss.count} solves") + parts).joinToString(" · "), Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 18.sp), color = Rk.c.inkDim) - Label(hint(m.timer, m.inspection, m.host.touchInput) + " ", color = Rk.c.inkDim, style = Rk.t.label) - RetroButton("leave display", { m.changeDisplayMode(false) }) + RetroButton("exit", { m.changeDisplayMode(false) }) } } } -- cgit v1.3