diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 09:36:32 -0800 |
| commit | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (patch) | |
| tree | 0b8726b1d3f493cbf5adcecf10997e3c72e27762 /ui/src/commonMain | |
| parent | 6004b4cda755202491648e0d724b66d50eb0b96a (diff) | |
| download | rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.tar.gz rookery-60426ce45763d0a4a5572db6926e8e677cbc0dc0.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'ui/src/commonMain')
7 files changed, 300 insertions, 135 deletions
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<ByteArray, String>? 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<String> + /** 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<String>() } fun newBatch(): List<String> { @@ -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<String>() } 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<TypeResult>, 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<TypeResult>, 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<String, BleAdvertisement>() + var scanning by mutableStateOf(false); private set + var status by mutableStateOf(""); private set + var connected by mutableStateOf<SmartCube?>(null); private set + var battery by mutableStateOf<Int?>(null); private set + var hardware by mutableStateOf<String?>(null); private set + /** 54 facelets, U R F D L B, as the cube reports itself. */ + var facelets by mutableStateOf<String?>(null); private set + var moves by mutableIntStateOf(0); private set + var lastMove by mutableStateOf<String?>(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<String?>(null); private set + var packet by mutableStateOf<StackmatPacket?>(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<String?>(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<Pair<PieceType, Int>?>(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<PieceType, Int>? = 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) }) } } } |
