summaryrefslogtreecommitdiff
path: root/ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt5
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt13
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt84
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt122
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt122
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt33
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt56
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) })
}
}
}