diff options
Diffstat (limited to 'ui/src/commonMain/kotlin/coffee')
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) }) } } } |
