summaryrefslogtreecommitdiff
path: root/ui/src/commonMain/kotlin/coffee/liz
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/commonMain/kotlin/coffee/liz')
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt222
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt42
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt214
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt85
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt46
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt38
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt11
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt50
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt6
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt51
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt2
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt6
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt7
-rw-r--r--ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt218
14 files changed, 870 insertions, 128 deletions
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt
new file mode 100644
index 0000000..0a64d24
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AlgScreen.kt
@@ -0,0 +1,222 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import coffee.liz.rookery.algs.AlgCase
+import coffee.liz.rookery.algs.AlgSets
+import coffee.liz.rookery.algs.AlgStatus
+import coffee.liz.rookery.model.formatTime
+import coffee.liz.rookery.monoMillis
+
+/** Last-layer colours with yellow on top and green in front, indexed U R F D L B. */
+private val PLAN_COLORS = listOf(Color(0xFFFFD500), Color(0xFFFF8A00), Color(0xFF2DB84D), Color(0xFFF4F4F4), Color(0xFFE02424), Color(0xFF2266E0))
+private val PLAN_OFF = Color(0xFF5A5F5C)
+
+/**
+ * A case seen from above: the top face with the side stickers folded out around it. For
+ * orientation sets only "top colour or not" matters, so everything else is grey.
+ */
+@Composable
+fun CasePlan(case: AlgCase, alg: String, size: Dp, modifier: Modifier = Modifier) {
+ val plan = remember(case.id, alg) { AlgSets.plan(case.state(alg)) }
+ val orientationOnly = case.set == "oll"
+ Canvas(modifier.size(size).semantics { contentDescription = "${case.set} ${case.name}" }) {
+ val thin = 0.34f // side strips are slivers
+ val unit = this.size.width / (3 + 2 * thin)
+ fun span(i: Int) = if (i == 0 || i == 4) thin * unit else unit
+ fun start(i: Int) = when (i) { 0 -> 0f; else -> thin * unit + (i - 1) * unit }
+ val gap = unit * 0.06f
+ for (r in 0 until 5) for (c in 0 until 5) {
+ val v = plan[r][c]
+ if (v < 0) continue
+ val color = if (orientationOnly) (if (v == 0) PLAN_COLORS[0] else PLAN_OFF) else PLAN_COLORS[v]
+ drawRect(Color(0xFF14181C), Offset(start(c), start(r)), Size(span(c), span(r)))
+ drawRect(color, Offset(start(c) + gap, start(r) + gap), Size(span(c) - 2 * gap, span(r) - 2 * gap))
+ }
+ }
+}
+
+/** Algorithm sets: look cases up, choose your algorithm for each, mark what you're learning, and drill it. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun AlgScreen(m: AppModel) {
+ var set by remember { mutableStateOf(AlgSets.names.first()) }
+ var drilling by remember { mutableStateOf(false) }
+ var selected by remember(set) { mutableStateOf<String?>(null) }
+ val version by m.repo.store.version.collectAsState()
+ val prefs = remember(version) { m.repo.algPrefs() }
+ fun status(c: AlgCase) = prefs[c.id]?.first ?: AlgStatus.NEW
+ fun alg(c: AlgCase) = prefs[c.id]?.second ?: c.algs.first()
+ val cases = AlgSets.cases(set)
+
+ Column(Modifier.fillMaxSize()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Tabs(AlgSets.names, set, { it.uppercase() }, { set = it })
+ Label(" ", color = Rk.c.inkDim)
+ Tabs(listOf(false, true), drilling, { if (it) "drill" else "cases" }, { drilling = it })
+ Spacer(Modifier.weight(1f))
+ val known = cases.count { status(it) == AlgStatus.KNOWN }; val learning = cases.count { status(it) == AlgStatus.LEARNING }
+ Label("$known known · $learning learning · ${cases.size}", style = Rk.t.label, color = Rk.c.inkDim)
+ }
+ Gap(6.dp)
+ if (drilling) { AlgDrill(m, cases, ::status, ::alg); return@Column }
+ Panes {
+ Column(Modifier.main().verticalScroll(rememberScrollState())) {
+ for ((group, inGroup) in cases.groupBy { it.group }) {
+ Label(group.lowercase(), color = Rk.c.heading, style = Rk.t.heading)
+ FlowRow(Modifier.padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ for (c in inGroup) {
+ val st = status(c)
+ Column(
+ Modifier.then(if (c.id == selected) Modifier.outline(Rk.c.accent, 2.dp) else Modifier.edge(Rk.c, sunk = true))
+ .clickable { selected = c.id }.padding(5.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ CasePlan(c, alg(c), 58.dp)
+ Label(c.name, style = Rk.t.label, color = when (st) { AlgStatus.KNOWN -> Rk.c.good; AlgStatus.LEARNING -> Rk.c.gold; AlgStatus.NEW -> Rk.c.inkDim })
+ }
+ }
+ }
+ }
+ }
+ Column(Modifier.side(400.dp).sideScroll()) {
+ val c = cases.firstOrNull { it.id == selected }
+ if (c == null) Label("no case selected", color = Rk.c.inkDim) else CaseDetail(m, c, status(c), alg(c))
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaseDetail(m: AppModel, c: AlgCase, status: AlgStatus, chosen: String) {
+ var custom by remember(c.id) { mutableStateOf(chosen.takeIf { it !in c.algs }.orEmpty()) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ CasePlan(c, chosen, 120.dp)
+ Spacer(Modifier.width(14.dp))
+ Column {
+ BasicText("${c.set.uppercase()} ${c.name}", style = Rk.t.h1.copy(fontSize = 26.sp, color = Rk.c.ink))
+ Label(c.group.lowercase(), color = Rk.c.inkDim)
+ Tabs(AlgStatus.entries, status, { it.name.lowercase() }, { m.repo.setAlgStatus(c.id, it) })
+ }
+ }
+ Hr()
+ for (a in c.algs) {
+ val sel = a == chosen
+ Label(
+ a, Modifier.fillMaxWidth().clickable { m.repo.setAlgChoice(c.id, a.takeIf { it != c.algs.first() }) }.padding(vertical = 4.dp),
+ style = Rk.t.mono.copy(fontSize = 15.sp), color = if (sel) Rk.c.accent else Rk.c.link,
+ )
+ }
+ Gap(4.dp)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(custom, { custom = it }, Modifier.weight(1f), placeholder = "your own", style = Rk.t.mono.copy(fontSize = 14.sp), description = "your own algorithm for ${c.name}")
+ val valid = remember(custom) {
+ // it has to actually solve this case: undo the usual algorithm, do yours, and the cube must be solved (any way up)
+ custom.isNotBlank() && runCatching {
+ val cube = coffee.liz.rookery.puzzle.NxnCube(3).apply(c.setup()).apply(AlgCase.clean(custom))
+ (0 until 6).all { f -> (0 until 9).all { cube.stickers[f * 9 + it] == cube.stickers[f * 9 + 4] } } ||
+ // or solved but for a turn of the top layer
+ listOf("U", "U2", "U'").any { u -> cube.copy().apply(u).let { t -> (0 until 6).all { f -> (0 until 9).all { t.stickers[f * 9 + it] == t.stickers[f * 9 + 4] } } } }
+ }.getOrDefault(false)
+ }
+ RetroButton("use", { m.repo.setAlgChoice(c.id, AlgCase.clean(custom)) }, enabled = valid)
+ }
+ if (custom.isNotBlank() && custom !in c.algs && chosen != AlgCase.clean(custom)) Label("only saved if it solves this case", color = Rk.c.inkDim, style = Rk.t.label)
+ Hr()
+ Label("setup ${c.setup(chosen)}", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim)
+}
+
+/**
+ * Drill: a case comes up (weak and unseen ones more often), you do it, then show the algorithm and
+ * say whether you had it. Cases marked "learning" are drilled; with none marked, the whole set is.
+ */
+@Composable
+private fun AlgDrill(m: AppModel, cases: List<AlgCase>, status: (AlgCase) -> AlgStatus, alg: (AlgCase) -> String) {
+ val pool = cases.filter { status(it) == AlgStatus.LEARNING }.ifEmpty { cases }
+ val ids = pool.map { "alg:${it.id}" }
+ val recent = remember { mutableStateListOf<String>() }
+ var current by remember(ids) { mutableStateOf(m.bld.pick(ids, recent)?.removePrefix("alg:")) }
+ var started by remember(current) { mutableLongStateOf(monoMillis()) }
+ var shownAfter by remember(current) { mutableStateOf<Long?>(null) }
+ val c = pool.firstOrNull { it.id == current } ?: run { Label("nothing to drill"); return }
+
+ fun reveal() { if (shownAfter == null) shownAfter = monoMillis() - started }
+ fun grade(ok: Boolean) {
+ m.bld.record("alg:${c.id}", ok, shownAfter ?: (monoMillis() - started), goodMs = 4_000); m.bld.tally(ok)
+ recent += "alg:${c.id}"
+ current = m.bld.pick(ids, recent)?.removePrefix("alg:")
+ }
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(current) { runCatching { focus.requestFocus() } }
+ Column(
+ Modifier.fillMaxSize().focusRequester(focus).focusable().onPreviewKeyEvent {
+ if (it.type != KeyEventType.KeyDown) false else when {
+ shownAfter == null && (it.key == Key.Spacebar || it.key == Key.Enter) -> { reveal(); true }
+ shownAfter != null && (it.key == Key.Spacebar || it.key == Key.Enter) -> { grade(true); true }
+ shownAfter != null && (it.key == Key.X || it.key == Key.Backspace) -> { grade(false); true }
+ else -> false
+ }
+ },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(Modifier.weight(1f), Alignment.Center) { CasePlan(c, alg(c), if (LocalNarrow.current) 220.dp else 280.dp) }
+ Label("setup ${c.setup(alg(c))}", style = Rk.t.mono.copy(fontSize = 14.sp), color = Rk.c.inkDim)
+ Gap()
+ val after = shownAfter
+ if (after == null) RetroButton("show", { reveal() }, description = "show the algorithm (space)")
+ else {
+ BasicText("${c.set.uppercase()} ${c.name}", style = Rk.t.heading.copy(color = Rk.c.heading))
+ BasicText(alg(c), style = Rk.t.mono.copy(fontSize = 22.sp, color = Rk.c.ink))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Label("${formatTime(after)} ", style = Rk.t.mono, color = Rk.c.inkDim)
+ RetroButton("had it", { grade(true) }, description = "had it (space)")
+ Label(" · ", color = Rk.c.inkDim)
+ RetroButton("missed", { grade(false) }, description = "missed (x)")
+ }
+ }
+ Gap()
+ if (pool.size == cases.size && cases.none { status(it) == AlgStatus.LEARNING }) Label("drilling the whole set; mark cases as learning to narrow it", color = Rk.c.inkDim, style = Rk.t.label)
+ }
+}
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 505a9d1..c714ba9 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/App.kt
@@ -4,6 +4,8 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -41,22 +43,27 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import coffee.liz.rookery.model.Penalty
import coffee.liz.rookery.timer.TimerMachine.State
import kotlinx.coroutines.delay
@Composable
-fun RookeryApp(m: AppModel) {
+fun RookeryApp(m: AppModel, insets: Modifier = Modifier) {
val dark = when (m.themeMode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
RookeryTheme(dark) {
val p = Rk.c
- Box(Modifier.fillMaxSize().background(p.bg)) {
- Column(Modifier.fillMaxSize().padding(horizontal = 14.dp, vertical = 8.dp)) {
+ // the page colour runs edge to edge; [insets] keeps content clear of system bars and the keyboard
+ // in display mode the timer takes the whole glass, camera cut-out and all
+ BoxWithConstraints(Modifier.fillMaxSize().background(p.bg).then(if (m.displayMode) Modifier else insets)) {
+ CompositionLocalProvider(LocalNarrow provides (maxWidth < 700.dp)) {
+ Column(Modifier.fillMaxSize().then(if (m.displayMode) Modifier else Modifier.padding(horizontal = if (LocalNarrow.current) 8.dp else 14.dp, vertical = if (LocalNarrow.current) 4.dp else 8.dp))) {
if (!m.displayMode) Header(m)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (m.tab) {
Tab.TIMER -> TimerScreen(m)
Tab.BLD -> BldScreen(m)
+ Tab.ALGS -> AlgScreen(m)
Tab.IMPORT -> ImportScreen(m)
Tab.SETTINGS -> SettingsScreen(m)
}
@@ -73,28 +80,39 @@ fun RookeryApp(m: AppModel) {
m.penguinSays != null && s is State.Stopped && s.penalty == Penalty.DNF -> Mood.SAD
else -> Mood.FREE
}
- PenguinFriend(mood, null, m.reducedMotion)
+ if (!m.displayMode) PenguinFriend(mood, null, m.reducedMotion, size = if (LocalNarrow.current) 40.dp else 64.dp) // the display is all timer
m.party?.let { PbParty(it.title, it.detail, m.reducedMotion) { m.party = null } }
+ }
}
}
}
@Composable
private fun Header(m: AppModel) {
- Row(verticalAlignment = Alignment.Bottom) {
- BasicText("rookery", style = Rk.t.h1.copy(color = Rk.c.heading))
- Spacer(Modifier.width(18.dp))
- Tabs(Tab.entries, m.tab, { it.label }, { m.tab = it })
+ val narrow = LocalNarrow.current
+ // one line on a phone: small title, short names
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ BasicText("rookery", style = Rk.t.h1.copy(color = Rk.c.heading, fontSize = if (narrow) 18.sp else Rk.t.h1.fontSize))
+ Spacer(Modifier.width(if (narrow) 8.dp else 18.dp))
+ Tabs(Tab.entries, m.tab, { if (narrow) it.short else it.label }, { m.tab = it }, style = if (narrow) Rk.t.body.copy(fontSize = 13.5.sp) else null)
}
- Hr()
+ if (narrow) Box(Modifier.fillMaxWidth().padding(vertical = 2.dp).height(2.dp).edge(Rk.c, sunk = true)) else Hr()
}
@Composable
private fun Footer(m: AppModel) {
- Hr()
+ val narrow = LocalNarrow.current
+ if (narrow) Box(Modifier.fillMaxWidth().padding(top = 2.dp).height(2.dp).edge(Rk.c, sunk = true)) else Hr()
Row(Modifier.height(31.dp), verticalAlignment = Alignment.CenterVertically) {
- Spacer(Modifier.width(84.dp)) // the penguin's spot
- if (m.tab == Tab.TIMER) RetroButton("display mode", { m.changeDisplayMode(true) }, style = Rk.t.label)
+ Spacer(Modifier.width(if (narrow) 58.dp else 84.dp)) // the penguin's spot
+ when (m.tab) {
+ Tab.TIMER -> {
+ if (narrow) { Tabs(listOf("timer", "solves", "stats"), m.timerPane, { it }, { m.timerPane = it }, style = Rk.t.label); Spacer(Modifier.weight(1f)) }
+ RetroButton(if (narrow) "display" else "display mode", { m.changeDisplayMode(true) }, style = Rk.t.label)
+ }
+ Tab.BLD, Tab.ALGS -> if (narrow && m.bld.sessionTotal > 0) Label("${m.bld.sessionRight}/${m.bld.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim)
+ else -> {}
+ }
}
}
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 cbd31bd..58e7259 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/AppModel.kt
@@ -48,11 +48,14 @@ interface Host {
val deviceId: String
/** Key for Brave image search; null if not configured. Local to this machine, never synced. */
val braveApiKey: String?
+ fun setBraveApiKey(key: String?)
/** True where fingers are the main input (phones, tablets): the timer then starts by touch. */
val touchInput: Boolean
/** Image search for letter pairs. Throws with a readable message if it can't. */
- suspend fun searchImages(query: String): List<ImageHit>
+ suspend fun searchImages(query: String, key: String): List<ImageHit>
suspend fun fetchBytes(url: String): ByteArray
+ /** Picture bytes from the system clipboard, or null if it holds no image. */
+ suspend fun clipboardImage(): ByteArray?
/** 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)
@@ -68,7 +71,7 @@ interface Host {
fun setLocalPref(key: String, value: String?)
}
-enum class Tab(val label: String) { TIMER("timer"), BLD("blindfolded"), IMPORT("import"), SETTINGS("settings") }
+enum class Tab(val label: String, val short: String = label) { TIMER("timer"), BLD("blindfolded", "bld"), ALGS("algs"), IMPORT("import"), SETTINGS("settings") }
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Everything derived from the store, recomputed off the UI thread when it changes. */
@@ -155,7 +158,7 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
init {
machine.inspection = inspection
machine.onFinish = ::finishSolve
- scope.launch { repo.store.version.collect { refresh() } }
+ scope.launch { repo.store.version.collect { refresh(); if (braveKey == null) braveKey = repo.pref("braveApiKey")?.takeIf { it.isNotBlank() } } }
scope.launch {
// Pick up ops other devices wrote into the sync folder.
while (true) { delay(2000); withContext(Dispatchers.Default) { repo.store.sync() } }
@@ -192,27 +195,180 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
fun createSession(event: Event) {
val id = randomId()
- val count = derived.sessions.count { it.event == event }
- repo.createSession(id, if (count == 0) "Main ${event.short}" else "${event.short} #${count + 1}", event, epochMillis())
+ // named for what and when, e.g. "3x3-2026-11-13"; a second one that day gets a number
+ val base = "${event.short.lowercase()}-${coffee.liz.rookery.today()}"
+ val taken = derived.sessions.map { it.name }.toSet()
+ val name = if (base !in taken) base else generateSequence(2) { it + 1 }.map { "$base-$it" }.first { it !in taken }
+ repo.createSession(id, name, event, epochMillis())
selectSession(id)
}
+ fun renameSession(id: String, name: String) { if (name.isNotBlank()) repo.renameSession(id, name.trim()) }
+
+ fun deleteSession(id: String) {
+ val next = derived.sessions.lastOrNull { it.id != id }
+ repo.deleteSession(id)
+ if (next != null) selectSession(next.id) // with none left, a fresh 3x3 session is made
+ }
+
+ /** The scramble before the current one, for when you moved on too soon. */
+ var previousScramble by mutableStateOf<String?>(null); private set
+ private var scrambleEvent: Event? = null
+
fun newScramble() {
val e = event
- scramble = "…"
- previewSvg = null
- previewState = null
+ // Leave the current scramble on screen until the next is ready, so nothing blanks or jumps;
+ // only a change of event clears it, because the old one would be for the wrong puzzle.
+ if (scrambleEvent != e) { scramble = ""; previousScramble = null; previewSvg = null; previewState = null }
scope.launch {
val s = withContext(Dispatchers.Default) { scrambler.next(e) }
val svg = withContext(Dispatchers.Default) { scrambler.previewSvg(e, s) }
val st = withContext(Dispatchers.Default) { runCatching { Puzzles.of(e.puzzle)?.solved()?.apply(s) }.getOrNull() }
- if (event == e) { scramble = s; previewSvg = svg; previewState = st }
+ if (event == e) {
+ if (scrambleEvent == e && scramble.isNotEmpty()) previousScramble = scramble
+ scrambleEvent = e
+ scramble = s; previewSvg = svg; previewState = st; resetTracker()
+ }
}
}
+ /** Which part of the timer a phone shows: "timer", "solves" or "stats". */
+ var timerPane by mutableStateOf("timer")
+ /** Show hundredths while the clock runs (off: whole seconds, which is calmer). */
+ var runningHundredths by mutableStateOf(repo.boolPref("runningHundredths", false)); private set
+ fun changeRunningHundredths(on: Boolean) { runningHundredths = on; repo.setPref("runningHundredths", on) }
+
// ---- timer input ----
- private fun sync() { timer = machine.state; lastActivity = monoMillis() }
+ private fun sync() {
+ val was = timer
+ timer = machine.state; lastActivity = monoMillis()
+ if (timer is TimerMachine.State.Running && was !is TimerMachine.State.Running) {
+ // a run has begun, however it was started: follow it on the cube if we can
+ // follow the run on the cube only if the cube is actually scrambled, and the mat isn't the timer
+ smartRun = smartActive && tracker.known && !tracker.solved && !stackmat.enabled
+ smartMoves.clear(); memoMs = null
+ countdownJob?.cancel(); countdownJob = null
+ }
+ }
+
+ // ---- timing with a smart cube ----
+
+ private val tracker = coffee.liz.rookery.puzzle.ScrambleTracker(scrambler::solve3x3)
+ /** What is left of the scramble on the connected cube. */
+ var smartPlan by mutableStateOf<List<String>>(emptyList()); private set
+ var smartCorrected by mutableStateOf(false); private set
+ /** The full sequence on show, which moves in it are done, and which one is in hand. */
+ var smartSequence by mutableStateOf<List<String>>(emptyList()); private set
+ var smartDone by mutableStateOf<Set<Int>>(emptySet()); private set
+ var smartCurrent by mutableStateOf(-1); private set
+ var smartKnown by mutableStateOf(false); private set
+ var smartScrambled by mutableStateOf(false); private set
+ /** True while the running solve is being followed on the cube. */
+ var smartRun by mutableStateOf(false); private set
+ /** Blind solves: when memo ended (first turn), in ms from the start. */
+ var memoMs by mutableStateOf<Long?>(null); private set
+ /** Seconds to wait after the cube is scrambled before inspection (or blind memo) starts by itself; -1 = never. */
+ var autoStart by mutableStateOf(repo.pref("cube.autoStart")?.toIntOrNull() ?: 2); private set
+ fun changeAutoStart(seconds: Int) { autoStart = seconds; repo.setPref("cube.autoStart", seconds.toString()) }
+ private val smartMoves = StringBuilder()
+ private var countdownJob: kotlinx.coroutines.Job? = null
+
+ val smartActive: Boolean
+ get() = cube.connected != null && event.puzzle == coffee.liz.rookery.model.Puzzle.CUBE3 && event != Event.E333FM && event != Event.E333MBF
+
+ private fun publish() {
+ smartSequence = tracker.sequence; smartDone = tracker.done; smartCurrent = tracker.current
+ smartPlan = tracker.plan; smartCorrected = tracker.corrected; smartKnown = tracker.known; smartScrambled = tracker.scrambled
+ }
+
+ private fun resetTracker() {
+ countdownJob?.cancel(); countdownJob = null
+ if (smartActive && scramble.isNotEmpty()) tracker.reset(scramble, cube.facelets)
+ publish()
+ }
+
+ private var lastMoveAt = 0L
+
+ /**
+ * The cube reports its whole state every so often. Moves can go missing over Bluetooth, so this
+ * is the final word on where the cube is, once it has been still long enough that the report
+ * can't be older than the last move we saw.
+ */
+ private fun onCubeFacelets(facelets: String) {
+ val now = monoMillis()
+ if (!tracker.known) {
+ if (smartActive) { tracker.reset(scramble, facelets); publish(); if (tracker.scrambled) onScrambled() } else tracker.correct(facelets)
+ liveFacelets = tracker.facelets
+ return
+ }
+ val settled = now - lastMoveAt > 400
+ val running = machine.state as? TimerMachine.State.Running
+ if (running != null && smartRun) {
+ // solved, whatever our own count of the moves says: stop at the last turn
+ if (facelets == coffee.liz.rookery.puzzle.Cube3x3.SOLVED && (settled || tracker.solved)) {
+ tracker.correct(facelets); liveFacelets = facelets
+ machine.finish(maxOf(lastMoveAt, running.since)); sync()
+ } else if (settled && tracker.correct(facelets)) liveFacelets = facelets
+ return
+ }
+ if (settled && tracker.correct(facelets)) {
+ liveFacelets = facelets
+ if (smartActive && running == null) { countdownJob?.cancel(); countdownJob = null; tracker.replanFromHere(); publish(); if (tracker.scrambled) onScrambled() }
+ }
+ }
+
+ /** The connected cube as it is right now (54 facelets), for drawing. */
+ var liveFacelets by mutableStateOf<String?>(null); private set
+
+ private fun onCubeMove(move: String) {
+ lastMoveAt = monoMillis()
+ if (!smartActive) { tracker.turn(move); liveFacelets = tracker.facelets; return } // keep our picture of the cube current regardless
+ val now = lastMoveAt
+ try { handleCubeMove(move, now) } finally { liveFacelets = tracker.facelets }
+ }
+
+ private fun handleCubeMove(move: String, now: Long) {
+ if (stackmat.enabled) {
+ // the mat does the timing; the cube only shows scramble progress
+ if (tracker.scrambled) tracker.turn(move) else { tracker.move(move); publish() }
+ return
+ }
+ fun record(since: Long) { smartMoves.append(move).append('@').append(now - since).append(' ') }
+ when (val s = machine.state) {
+ is TimerMachine.State.Running -> if (!smartRun) tracker.turn(move) else {
+ if (event.blind && memoMs == null) memoMs = now - s.since
+ record(s.since); tracker.turn(move)
+ if (tracker.solved) { machine.finish(now); sync() }
+ }
+ is TimerMachine.State.Inspecting -> { machine.beginRunning(now); sync(); record(now); tracker.turn(move) }
+ else -> {
+ val wasScrambled = tracker.scrambled
+ if (wasScrambled && countdownJob == null && !event.blind) {
+ // scrambled and nothing pending: the first turn starts the clock
+ machine.beginRunning(now); sync(); record(now); tracker.turn(move)
+ return
+ }
+ countdownJob?.cancel(); countdownJob = null
+ tracker.move(move); publish()
+ if (!wasScrambled && tracker.scrambled) onScrambled()
+ }
+ }
+ }
+
+ private fun onScrambled() {
+ if (stackmat.enabled) return
+ if (autoStart < 0 || (!event.blind && !inspection)) return
+ countdownJob?.cancel()
+ countdownJob = scope.launch {
+ delay(autoStart * 1000L)
+ countdownJob = null
+ if (!tracker.scrambled || !(machine.state is TimerMachine.State.Idle || machine.state is TimerMachine.State.Stopped)) return@launch
+ val now = monoMillis()
+ if (event.blind) machine.beginRunning(now) else machine.beginInspection(now)
+ sync()
+ }
+ }
fun press() { machine.press(monoMillis()); sync() }
fun release() { machine.release(monoMillis()); sync() }
@@ -235,7 +391,18 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
private fun finishSolve(ms: Long, penalty: Penalty) {
val session = derived.session ?: return
- val solve = Solve(randomId(), session.id, session.event, ms, penalty, scramble, "", epochMillis())
+ val followed = smartRun
+ smartRun = false
+ // a start and stop in the same breath is a slip of the finger, not a solve
+ if (ms < 250 && penalty != Penalty.DNF) return
+ val solve = Solve(
+ randomId(), session.id, session.event, ms,
+ // stopped by hand with the cube still unsolved
+ if (followed && !tracker.solved && penalty != Penalty.DNF) Penalty.DNF else penalty,
+ scramble, "", epochMillis(),
+ moves = smartMoves.toString().trim().takeIf { followed && it.isNotEmpty() },
+ memoMs = memoMs.takeIf { followed },
+ )
val before = derived.eventStats
val priorResults = repo.solves().filter { it.event == session.event }.map { it.result }
repo.addSolve(solve)
@@ -350,10 +517,28 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
val (data, ext) = host.shrinkImage(bytes) ?: (bytes to path.substringAfterLast('.', "jpg"))
repo.setPairImage(pair, repo.store.blobs.put(data, ext))
}
+
+ /** Keep whatever picture is on the clipboard as [pair]'s image; reports through [imageStatus]. */
+ fun setPairImageFromClipboard(pair: String) = scope.launch {
+ imageStatus = "pasting…"
+ val stored = withContext(Dispatchers.Default) { host.clipboardImage()?.let { host.shrinkImage(it) } }
+ if (stored == null) { imageStatus = "no picture on the clipboard."; return@launch }
+ repo.setPairImage(pair, repo.store.blobs.put(stored.first, stored.second))
+ imageStatus = ""
+ }
fun blob(name: String): ByteArray? = repo.store.blobs.get(name)
// ---- image search ----
+ /**
+ * The Brave Search key travels with the synced data, so entering it once covers every device.
+ * A key in this machine's own config (or environment) still works and is copied into the sync
+ * the first time it is seen.
+ */
+ var braveKey by mutableStateOf(repo.pref("braveApiKey")?.takeIf { it.isNotBlank() } ?: host.braveApiKey?.also { repo.setPref("braveApiKey", it) })
+ private set
+ fun changeBraveKey(key: String?) { repo.setPref("braveApiKey", key?.trim()?.takeIf { it.isNotEmpty() }); if (key == null) host.setBraveApiKey(null); braveKey = repo.pref("braveApiKey") }
+
var imageHits by mutableStateOf<List<ImageHit>>(emptyList()); private set
var imageStatus by mutableStateOf(""); private set
val thumbs = androidx.compose.runtime.mutableStateMapOf<String, ByteArray>()
@@ -364,7 +549,7 @@ class AppModel(val repo: Repo, private val scrambler: Scrambler, val host: Host,
imageHits = emptyList(); thumbs.clear(); imageStatus = "searching…"
imageJob = scope.launch {
try {
- val hits = host.searchImages(query)
+ val hits = host.searchImages(query, braveKey ?: error("No Brave Search key (see settings)."))
imageHits = hits
imageStatus = if (hits.isEmpty()) "nothing found for “$query”." else ""
for (h in hits) launch { runCatching { host.fetchBytes(h.thumbnail) }.onSuccess { thumbs[h.thumbnail] = it } }
@@ -392,6 +577,9 @@ 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 cube = CubeModel(repo, host.cubes, scope).also {
+ it.onMove = { e -> onCubeMove(e.move) }
+ it.onFacelets = ::onCubeFacelets
+ }
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 f5c9dba..3695abb 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/BldScreen.kt
@@ -71,19 +71,20 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.random.Random
-enum class BldMode(val label: String) {
- P2W("pair → word"), W2P("word → pair"), TARGETS("targets"), MEMO("memo"), PAIRS("letter pairs")
+enum class BldMode(val label: String, val short: String = label) {
+ P2W("pair → word", "p→w"), W2P("word → pair", "w→p"), TARGETS("targets"), MEMO("memo"), PAIRS("letter pairs", "pairs")
}
@Composable
fun BldScreen(m: AppModel) {
val mode = m.bldMode
Column(Modifier.fillMaxSize()) {
+ val narrow = LocalNarrow.current
Row(verticalAlignment = Alignment.CenterVertically) {
- Tabs(BldMode.entries, mode, { it.label }, { m.bldMode = it })
- Spacer(Modifier.weight(1f))
+ Tabs(BldMode.entries, mode, { if (narrow) it.short else it.label }, { m.bldMode = it }, Modifier.weight(1f), style = if (narrow) Rk.t.label else null)
val b = m.bld
- if (b.sessionTotal > 0) Label("${b.sessionRight}/${b.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim)
+ // on a phone the score sits in the footer, out of the tabs' way
+ if (!narrow && b.sessionTotal > 0) Label("${b.sessionRight}/${b.sessionTotal}", style = Rk.t.mono, color = Rk.c.inkDim)
}
Gap(6.dp)
Box(Modifier.weight(1f).fillMaxWidth()) {
@@ -128,9 +129,14 @@ private fun PairToWord(m: AppModel) {
if (keys.isEmpty()) { Label("no letter pairs yet"); return }
val recent = remember { mutableStateListOf<String>() }
+ val narrow = LocalNarrow.current
+ val cubeMapping = remember { CubeMapping(Puzzles.cube(3), 3) }
+ val cubeColors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ val solvedCube = remember(bld.setup) { cubeMapping.state(bld.setup.solvedColors) }
+ val perBatch = if (narrow) 1 else 3
fun newBatch(): List<String> {
val out = mutableListOf<String>()
- repeat(minOf(3, keys.size)) { bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } }
+ repeat(minOf(perBatch, keys.size)) { bld.pick(keys.map { "p2w:$it" }, recent + out.map { "p2w:$it" })?.removePrefix("p2w:")?.let { out += it } }
return out
}
var batch by remember { mutableStateOf(newBatch()) }
@@ -162,13 +168,22 @@ private fun PairToWord(m: AppModel) {
val lp = pairs[pair]
val miss = pair in missed
val where = remember(pair, bld.setup) { pairOnCube(bld.setup, pair, Random.Default) }
- Table(Modifier.width(280.dp).fillMaxHeight().clickable(enabled = revealedAfter != null) { if (miss) missed.remove(pair) else missed.add(pair) }) {
+ Table((if (narrow) Modifier.weight(1f) else Modifier.width(280.dp)).fillMaxHeight().clickable(enabled = revealedAfter != null) { if (miss) missed.remove(pair) else missed.add(pair) }) {
Column(Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
BasicText(pair, style = Rk.t.mono.copy(fontSize = 64.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink))
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)
+ val slots = listOf(Cube3.slotOf(type, a), Cube3.slotOf(type, b))
+ val camera = remember(pair, where) { cornerView(Cube3.bestView(slots)).let { (y, p) -> Camera(y, p) } }
+ PuzzleView(
+ solvedCube, cubeColors, Modifier.size(180.dp), camera, description = "$pair on the cube; drag to spin", draggable = true,
+ marks = mapOf(cubeMapping.stickerOfSlot(slots[0]) to "1", cubeMapping.stickerOfSlot(slots[1]) to "2"), ghost = true,
+ arrow = cubeMapping.stickerOfSlot(slots[0]) to cubeMapping.stickerOfSlot(slots[1]),
+ )
Label("${if (type == PieceType.CORNER) "corners" else "edges"}: ${Cube3.stickerName(type, a)} → ${Cube3.stickerName(type, b)}", color = Rk.c.inkDim, style = Rk.t.label)
+ } else {
+ // same piece, or on a buffer, both as corners and as edges: it never comes up in a solve
+ Label("not a possible pair with your buffers", color = Rk.c.inkDim, style = Rk.t.label)
}
}
Column(Modifier.fillMaxWidth().weight(1f).edge(Rk.c, sunk = true).padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -179,7 +194,7 @@ private fun PairToWord(m: AppModel) {
val others = lp.words - setOfNotNull(lp.primary)
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) "missed" else "${i + 1}: mark missed", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label)
+ Label(if (miss) "missed" else if (narrow) "tap: mark missed" else "${i + 1}: mark missed", color = if (miss) Rk.c.bad else Rk.c.inkDim, style = Rk.t.label)
}
}
}
@@ -308,47 +323,42 @@ private fun Targets(m: AppModel) {
val letter = setup.scheme.letter(q.type, q.home)
val kind = if (q.type == PieceType.CORNER) "corner" else "edge"
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
- Box(Modifier.weight(1f).fillMaxHeight(), Alignment.Center) {
+ Panes {
+ Box(Modifier.main(), Alignment.Center) {
if (find) CubeNet(
- q.state, 44.dp,
+ q.state, if (LocalNarrow.current) 30.dp else 44.dp,
selected = whereSlot.takeIf { verdict == false },
describe = { "sticker" },
onPick = { slot -> if (Cube3.positionOf(PieceType.CORNER, slot) != null || Cube3.positionOf(PieceType.EDGE, slot) != null) answer(slot == whereSlot) },
)
else PuzzleView(mapping.state(q.state), colors, Modifier.fillMaxSize(), camera, description = "scrambled cube with one $kind sticker ringed", highlight = mapping.stickerOfSlot(whereSlot))
}
- Column(Modifier.width(330.dp)) {
+ Column(Modifier.side(330.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Tabs(listOf(false, true), find, { if (it) "find" else "name" }, { find = it })
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- 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)
+ Label(if (find) "find the $kind" else "name the ringed $kind", Modifier.weight(1f), color = Rk.c.inkDim)
+ // the answer shows here, above the input, where a keyboard can't cover it
+ verdict?.let { ok ->
+ Label("$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))
}
}
- Hr()
- if (find) {
- 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(kind, color = Rk.c.inkDim)
- Gap(6.dp)
+ if (find) BasicText("$letter", style = Rk.t.mono.copy(fontSize = 72.sp, fontWeight = FontWeight.Bold, color = Rk.c.ink))
+ else {
val focus = remember { FocusRequester() }
LaunchedEffect(q) { runCatching { focus.requestFocus() } }
RetroField(
typed,
{ v -> val c = v.uppercase().filter { it.isLetter() }.take(1); typed = c; if (c.isNotEmpty()) answer(setup.scheme.position(q.type, c[0]) == q.home) },
- Modifier.width(90.dp).focusRequester(focus), style = Rk.t.mono.copy(fontSize = 56.sp, textAlign = TextAlign.Center), description = "letter for the ringed sticker",
+ Modifier.width(90.dp).focusRequester(focus), style = Rk.t.mono.copy(fontSize = 48.sp, textAlign = TextAlign.Center), description = "letter for the ringed sticker",
)
}
- Gap()
- verdict?.let { ok ->
- Label(
- "$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),
- )
+ Hr()
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Tabs(listOf(false, true), find, { if (it) "find" else "name" }, { find = it })
+ Label(" ", 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)
+ }
}
}
}
@@ -459,8 +469,8 @@ private fun MemoTrainer(m: AppModel) {
m.say(if (solved) "squawk!" else "peck.")
}
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
- Column(Modifier.weight(1f).fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Panes {
+ Column(Modifier.main(), horizontalAlignment = Alignment.CenterHorizontally) {
when {
state == null -> Box(Modifier.fillMaxSize(), Alignment.Center) { Label("…", color = Rk.c.inkDim) }
phase == MemoPhase.EXECUTE || phase == MemoPhase.RECALL -> Box(Modifier.fillMaxSize(), Alignment.Center) {
@@ -472,7 +482,8 @@ private fun MemoTrainer(m: AppModel) {
}
else -> {
PuzzleView(
- mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube; the view follows what you type",
+ mapping.state(state), colors, Modifier.fillMaxWidth().weight(1f), camera, description = "scrambled cube; the view follows what you type, and you can drag it",
+ draggable = true, // look around freely; the next letter typed brings the view back
highlight = lookSlot?.takeIf { phase == MemoPhase.MEMO }?.let { mapping.stickerOfSlot(it) },
)
if (phase == MemoPhase.MEMO) Row(verticalAlignment = Alignment.CenterVertically) {
@@ -486,7 +497,7 @@ private fun MemoTrainer(m: AppModel) {
}
}
}
- Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) {
+ Column(Modifier.side(400.dp).sideScroll()) {
Label(scramble ?: "…", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim)
BasicText(formatTime(if (phase == MemoPhase.MEMO) now - memoStart else memoMs), style = Rk.t.mono.copy(fontSize = 40.sp, color = Rk.c.ink))
when (phase) {
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt
index a2c2857..ac609f0 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Devices.kt
@@ -7,6 +7,8 @@ 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.GanDefinitions
+import kotlinx.coroutines.flow.first
import coffee.liz.rookery.bt.SmartCube
import coffee.liz.rookery.bt.SmartCubeEvent
import coffee.liz.rookery.model.CubeDevice
@@ -36,6 +38,10 @@ class CubeModel(private val repo: Repo, val backend: CubeBackend?, private val s
var lastMove by mutableStateOf<String?>(null); private set
private var scanJob: Job? = null
private var linkJob: Job? = null
+ /** Hooks for whoever is timing with the cube. */
+ var onMove: (SmartCubeEvent.Move) -> Unit = {}
+ var onFacelets: (String) -> Unit = {}
+ private var pollJob: Job? = null
fun scan(seconds: Long = 15) {
val b = backend ?: return
@@ -51,20 +57,52 @@ class CubeModel(private val repo: Repo, val backend: CubeBackend?, private val s
fun stopScan() { scanJob?.cancel(); scanning = false }
- fun connect(address: String, name: String?, mac: String = address) {
+ private val macShape = Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
+
+ /** The cube's real MAC, which its encryption is keyed on: from its advertisement, its address, or a cube we already know by name. */
+ private fun macOf(adv: BleAdvertisement): String? =
+ GanDefinitions.macFromManufacturerData(adv.manufacturerData)
+ ?: adv.address.takeIf { macShape.matches(it) }
+ ?: repo.devices().firstOrNull { it.name == adv.name }?.mac
+
+ /** Pair with a cube heard while scanning. */
+ fun pair(adv: BleAdvertisement) {
+ val mac = macOf(adv)
+ if (mac == null) { status = "can't read this cube's MAC from its advertisement"; return }
+ connect(adv.address, adv.name, mac)
+ }
+
+ /** Connect to a saved cube: listen for it first, since its address differs per platform. */
+ fun connectKnown(d: CubeDevice) {
val b = backend ?: return
stopScan(); linkJob?.cancel()
+ status = "looking for ${d.name}"
+ linkJob = scope.launch {
+ val adv = try {
+ withTimeoutOrNull(12_000) { b.scan().first { a -> macOf(a)?.equals(d.mac, true) == true || (a.name != null && a.name == d.name) } }
+ } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { status = e.message ?: "scan failed"; return@launch }
+ if (adv == null) { status = "can't hear ${d.name}. turn it to wake it"; return@launch }
+ connect(adv.address, adv.name ?: d.name, d.mac)
+ }
+ }
+
+ fun connect(address: String, name: String?, mac: String = address) {
+ val b = backend ?: return
+ stopScan(); val previous = linkJob
status = "connecting"
linkJob = scope.launch {
+ if (previous != kotlin.coroutines.coroutineContext[Job]) previous?.cancel()
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() }
+ // ask for the whole state regularly: it is what catches a lost move and confirms "solved"
+ launch { delay(2500); while (true) { runCatching { cube.requestFacelets() }; delay(2500) } }
cube.events.collect { e ->
when (e) {
- is SmartCubeEvent.Move -> { moves++; lastMove = e.move }
- is SmartCubeEvent.Facelets -> facelets = e.facelets
+ is SmartCubeEvent.Move -> { moves++; lastMove = e.move; onMove(e); if (e.afterGap) launch { runCatching { cube.requestFacelets() } } }
+ is SmartCubeEvent.Facelets -> { facelets = e.facelets; onFacelets(e.facelets) }
is SmartCubeEvent.Battery -> battery = e.percent
is SmartCubeEvent.Hardware -> hardware = listOfNotNull(e.name, e.softwareVersion?.let { "sw $it" }).joinToString(" ")
is SmartCubeEvent.Gyro -> {}
@@ -114,7 +152,7 @@ class StackmatModel(private val host: Host, private val scope: CoroutineScope, o
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 } }
+ scope.launch { while (enabled) { delay(500); if (coffee.liz.rookery.monoMillis() - lastPacketAt > 3000) live = false } }
} catch (e: Exception) { enabled = false; error = e.message ?: "couldn't open the input" }
}
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 6ed1cdf..f0d1778 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/OtherScreens.kt
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -53,10 +54,12 @@ fun SettingsScreen(m: AppModel) {
val scope = rememberCoroutineScope()
var syncPath by remember { mutableStateOf(m.host.syncFolder) }
var moved by remember { mutableStateOf(false) }
- Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) {
- Column(Modifier.weight(1f).widthIn(max = 560.dp)) {
+ val narrow = LocalNarrow.current
+ val body: @Composable (Modifier, Modifier) -> Unit = { left, right ->
+ Column(left) {
Heading("timer")
Checkbox("WCA inspection", m.inspection, m::changeInspection)
+ Checkbox("show hundredths while running", m.runningHundredths, m::changeRunningHundredths)
Checkbox("sound", m.sound, m::changeSound)
Row {
RetroButton("test PB sound", { m.testChime() })
@@ -81,25 +84,34 @@ fun SettingsScreen(m: AppModel) {
if (moved) Label("Restart to apply.", color = Rk.c.gold)
Hr()
Heading("image search")
- Label(
- 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,
- )
+ var key by remember { mutableStateOf("") }
+ if (m.braveKey != null) Row(verticalAlignment = Alignment.CenterVertically) {
+ Label("Brave key set (synced) ")
+ RetroButton("remove", { m.changeBraveKey(null) })
+ } else Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(key, { key = it.trim() }, Modifier.weight(1f), placeholder = "Brave Search API key", style = Rk.t.mono.copy(fontSize = 13.sp), description = "Brave Search API key")
+ RetroButton("save", { m.changeBraveKey(key); key = "" }, enabled = key.isNotBlank())
+ }
Hr()
SmartCubes(m)
Hr()
Stackmat(m)
}
- Column(Modifier.weight(1f).widthIn(max = 560.dp)) { BldSetup(m) }
+ Column(right) { if (narrow) Hr(); BldSetup(m) }
}
+ if (narrow) Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { body(Modifier.fillMaxWidth(), Modifier.fillMaxWidth()) }
+ else Row(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(28.dp)) { body(Modifier.weight(1f).widthIn(max = 560.dp), Modifier.weight(1f).widthIn(max = 560.dp)) }
}
@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 }
+ if (c.backend == null) { Label("no Bluetooth on this platform", color = Rk.c.inkDim); return }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Label("auto-start after scramble: ", color = Rk.c.inkDim)
+ Tabs(listOf(-1, 0, 1, 2, 3), m.autoStart, { if (it < 0) "off" else "${it}s" }, { m.changeAutoStart(it) }, style = Rk.t.label)
+ }
val cube = c.connected
if (cube != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -119,16 +131,16 @@ private fun SmartCubes(m: AppModel) {
}
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 }
+ val nearby = c.found.values.filter { it.address.uppercase() !in knownMacs && known.none { k -> k.name == it.name } }.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) }
+ Row(Modifier.weight(2f)) { RetroButton("connect", { c.connectKnown(d) }, 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(Modifier.weight(2f)) { RetroButton("pair", { c.pair(a) }, 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) {
@@ -180,7 +192,7 @@ private fun BldSetup(m: AppModel) {
Label("lettering (click a sticker to change it)", color = Rk.c.inkDim)
Gap(6.dp)
CubeNet(
- setup.solvedColors, 34.dp,
+ setup.solvedColors, if (LocalNarrow.current) 27.dp else 34.dp,
text = { slot -> target(slot)?.let { (t, pos) -> setup.scheme.letter(t, pos).toString() } },
selected = editing?.let { Cube3.slotOf(it.first, it.second) },
describe = { slot -> target(slot)?.let { (t, pos) -> "${Cube3.stickerName(t, pos)} ${setup.scheme.letter(t, pos)}" } },
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 dfa241c..2a5a791 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PairsScreen.kt
@@ -62,8 +62,8 @@ fun PairsScreen(m: AppModel) {
val glance = query.uppercase().filter { it in LETTERS }.take(2)
val shown = if (glance.length == 2) glance else selected
- Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
- Column(Modifier.weight(1f).fillMaxHeight()) {
+ Panes {
+ Column(Modifier.main()) {
Row(verticalAlignment = Alignment.CenterVertically) {
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")
@@ -73,7 +73,7 @@ fun PairsScreen(m: AppModel) {
Gap(6.dp)
PairGrid(m.derived.pairs, shown) { selected = it; query = ""; m.clearImageSearch() }
}
- Column(Modifier.width(400.dp).fillMaxHeight().verticalScroll(rememberScrollState())) {
+ Column(Modifier.side(400.dp).sideScroll()) {
if (shown != null) PairCard(m, shown, m.derived.pairs[shown]) else Label("no pair selected", color = Rk.c.inkDim)
}
}
@@ -148,10 +148,13 @@ private fun PairCard(m: AppModel, pair: String, lp: LetterPair?) {
RetroButton("search", { finding = !finding; if (finding && query.isNotBlank()) m.searchImages(query) else m.clearImageSearch() }, selected = finding)
Label(" · ", color = p.inkDim)
RetroButton("file", { m.setPairImageFromFile(pair) })
+ Label(" · ", color = p.inkDim)
+ RetroButton("paste", { m.setPairImageFromClipboard(pair) }, description = "use the picture on the clipboard")
if (lp?.image != null) { Label(" · ", color = p.inkDim); RetroButton("remove", { m.repo.setPairImage(pair, null) }) }
}
+ if (!finding && m.imageStatus.isNotEmpty()) Label(m.imageStatus, Modifier.padding(horizontal = 6.dp, vertical = 2.dp), color = p.gold, style = Rk.t.label)
if (finding) Column(Modifier.fillMaxWidth().edge(p, sunk = true).padding(6.dp)) {
- if (m.host.braveApiKey == null) Label("No Brave key (see settings).", color = p.gold)
+ if (m.braveKey == null) Label("No Brave key (see settings).", color = p.gold)
else {
Row(verticalAlignment = Alignment.CenterVertically) {
RetroField(query, { query = it }, Modifier.weight(1f), placeholder = "search", onSubmit = { m.searchImages(query) }, description = "image search")
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt
new file mode 100644
index 0000000..8ff13ab
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Panes.kt
@@ -0,0 +1,50 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** True on phone-width windows, where side-by-side panes stack instead. */
+val LocalNarrow = staticCompositionLocalOf { false }
+
+interface PaneScope {
+ /** The pane that takes whatever room is left. */
+ fun Modifier.main(): Modifier
+ /** The fixed pane: [width] wide beside the main one, or full width under it when stacked. */
+ fun Modifier.side(width: Dp): Modifier
+ /** Lets the side pane scroll by itself when panes sit side by side (stacked, the whole page scrolls instead). */
+ fun Modifier.sideScroll(): Modifier
+}
+
+/** Two panes: side by side on a wide window, stacked on a narrow one. */
+@Composable
+fun Panes(modifier: Modifier = Modifier, content: @Composable PaneScope.() -> Unit) {
+ // Stacked, the whole thing scrolls, so an on-screen keyboard can't squeeze the main pane away.
+ if (LocalNarrow.current) Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ object : PaneScope {
+ override fun Modifier.main() = fillMaxWidth().height(300.dp)
+ override fun Modifier.side(width: Dp) = fillMaxWidth()
+ override fun Modifier.sideScroll() = this
+ }.content()
+ } else Row(modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ val scope = this
+ val scroll = rememberScrollState()
+ object : PaneScope {
+ override fun Modifier.main() = with(scope) { weight(1f) }.fillMaxHeight()
+ override fun Modifier.side(width: Dp) = width(width).fillMaxHeight()
+ override fun Modifier.sideScroll() = verticalScroll(scroll)
+ }.content()
+ }
+}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
index 548451f..5dca583 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Penguin.kt
@@ -73,12 +73,12 @@ private const val SPRITE = 32
fun penguinSheet(): ImageBitmap = imageResource(Res.drawable.penguin_sheet)
/** Draw one frame [sizePx] pixels square, nearest-neighbour so the pixels stay crisp at any density. */
-fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, sizePx: Float, alpha: Float = 1f) {
+fun DrawScope.drawPenguin(sheet: ImageBitmap, cell: Pair<Int, Int>, at: Offset, sizePx: Float, alpha: Float = 1f, colorFilter: androidx.compose.ui.graphics.ColorFilter? = null) {
drawImage(
sheet,
srcOffset = IntOffset(cell.first * SPRITE, cell.second * SPRITE), srcSize = IntSize(SPRITE, SPRITE),
dstOffset = IntOffset(at.x.roundToInt(), at.y.roundToInt()), dstSize = IntSize(sizePx.roundToInt(), sizePx.roundToInt()),
- alpha = alpha, filterQuality = FilterQuality.None,
+ alpha = alpha, colorFilter = colorFilter, filterQuality = FilterQuality.None,
)
}
@@ -109,7 +109,7 @@ fun PenguinFriend(mood: Mood, says: String?, reducedMotion: Boolean, size: Dp =
val sizePx = with(LocalDensity.current) { size.toPx() }
BoxWithConstraints(Modifier.fillMaxSize().clearAndSetSemantics { }) {
val h = constraints.maxHeight.toFloat()
- val pos = Offset(sizePx * 0.9f, h - sizePx * 0.8f)
+ val pos = Offset(sizePx * 0.9f, h - sizePx * 0.62f)
var anim by remember { mutableStateOf(PenguinAnim.IDLE) }
var frame by remember { mutableIntStateOf(0) }
LaunchedEffect(mood, reducedMotion) {
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
index c979239..6258513 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/PuzzleView.kt
@@ -18,7 +18,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.drawText
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import coffee.liz.rookery.puzzle.Polygon
import coffee.liz.rookery.puzzle.PuzzleState
import coffee.liz.rookery.puzzle.Turn
@@ -91,7 +93,13 @@ fun PuzzleView(
onStickerTap: ((Int) -> Unit)? = null,
/** Let the user spin it by dragging (left/right spins, up/down tilts). */
draggable: Boolean = false,
+ /** Stickers to label. With [ghost], everything else is drawn faint and see-through so these show even from behind. */
+ marks: Map<Int, String> = emptyMap(),
+ ghost: Boolean = false,
+ /** Draw an arrow from one sticker to another, through the cube if need be. */
+ arrow: Pair<Int, Int>? = null,
) {
+ val measurer = androidx.compose.ui.text.rememberTextMeasurer()
val puzzle = state.puzzle
val body = Color(0xFF14181C)
val hits = remember { ArrayList<Pair<Int, List<Offset>>>() }
@@ -124,7 +132,7 @@ fun PuzzleView(
Triple(a.turn, axis.normal, -2 * PI * a.turn.amount / axis.order * a.progress)
}
- class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float)
+ class Drawn(val index: Int, val tile: Polygon, val color: Color, val depth: Double, val shade: Float, val front: Boolean)
val drawn = ArrayList<Drawn>(puzzle.stickers.size)
for ((i, s) in puzzle.stickers.withIndex()) {
var poly = s
@@ -133,25 +141,54 @@ fun PuzzleView(
}
val v = poly.map(::view)
val n = v.normal
- if ((n dot (V3(0.0, 0.0, dist) - v.centroid)) <= 0) continue // back face
+ val front = (n dot (V3(0.0, 0.0, dist) - v.centroid)) > 0
+ if (!front && !(ghost && i in marks)) continue // back faces only matter when a marked sticker is on one
val lambert = maxOf(0.0, n dot light).toFloat()
- drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, 0.62f + 0.38f * lambert)
+ drawn += Drawn(i, v, faceColors[state.colors[i] % faceColors.size], v.centroid.z, if (front) 0.62f + 0.38f * lambert else 0.8f, front)
}
drawn.sortBy { it.depth }
hits.clear()
for (d in drawn) {
val tile = d.tile.points.map(::project)
- fill(tile, body)
+ val faint = ghost && d.index !in marks
+ fill(tile, body.copy(alpha = if (faint) 0.55f else 1f))
val sticker = d.tile.inset(0.86).points.map(::project)
- fill(sticker, d.color.shaded(d.shade))
- drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1.dp.toPx()))
- hits += d.index to tile
+ fill(sticker, d.color.shaded(d.shade).copy(alpha = if (faint) 0.45f else 1f))
+ if (!faint) drawPath(path(sticker), Color.White.copy(alpha = 0.10f * d.shade), style = Stroke(1.dp.toPx()))
+ if (d.front) hits += d.index to tile
}
if (highlight != null) drawn.firstOrNull { it.index == highlight }?.let { d ->
val ring = d.tile.inset(0.97).points.map(::project)
drawPath(path(ring), Color.Black, style = Stroke(5.dp.toPx()))
drawPath(path(ring), highlightColor, style = Stroke(3.dp.toPx()))
}
+ fun centre(i: Int) = project(view(puzzle.stickers[i].centroid))
+ if (arrow != null) {
+ val from = centre(arrow.first); val to = centre(arrow.second)
+ val d = to - from
+ val len = d.getDistance()
+ if (len > 1f) {
+ val u = d / len; val normal = Offset(-u.y, u.x)
+ val head = 11.dp.toPx()
+ val tip = to - u * 6.dp.toPx() // stop just short, so the label under it stays readable
+ val base = tip - u * head
+ val shaft = Path().apply { moveTo(from.x, from.y); lineTo(base.x, base.y) }
+ val arrowHead = Path().apply {
+ moveTo(tip.x, tip.y); lineTo(base.x + normal.x * head * 0.5f, base.y + normal.y * head * 0.5f)
+ lineTo(base.x - normal.x * head * 0.5f, base.y - normal.y * head * 0.5f); close()
+ }
+ drawPath(shaft, Color.Black, style = Stroke(6.dp.toPx())); drawPath(arrowHead, Color.Black, style = Stroke(3.dp.toPx()))
+ drawPath(shaft, highlightColor, style = Stroke(3.dp.toPx())); drawPath(arrowHead, highlightColor)
+ drawCircle(Color.Black, 5.dp.toPx(), from); drawCircle(highlightColor, 3.5.dp.toPx(), from)
+ }
+ }
+ for ((i, label) in marks) {
+ val at = centre(i)
+ val layout = measurer.measure(label, androidx.compose.ui.text.TextStyle(color = Color.Black, fontSize = 11.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace))
+ val r = maxOf(layout.size.width, layout.size.height) * 0.55f
+ drawCircle(Color.Black, r + 1.5.dp.toPx(), at); drawCircle(Color.White, r, at)
+ drawText(layout, topLeft = Offset(at.x - layout.size.width / 2f, at.y - layout.size.height / 2f))
+ }
}
}
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
index 2deaac7..9bf6685 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Retro.kt
@@ -120,7 +120,7 @@ fun RetroButton(
withStyle(
when {
!enabled -> SpanStyle(color = p.inkDim)
- selected -> SpanStyle(color = p.accent, fontWeight = FontWeight.Bold)
+ selected -> SpanStyle(color = p.accent) // colour only: bold would change the width and re-wrap the row
else -> SpanStyle(color = if (hovered) p.accent else p.link, textDecoration = TextDecoration.Underline)
},
) { append(text) }
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt
index ae69607..b5a5529 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/SevenSegment.kt
@@ -15,12 +15,14 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
* and enough letters for "dnF".
*/
@Composable
-fun SevenSegment(text: String, on: Color, ghost: Color, modifier: Modifier = Modifier) {
+fun SevenSegment(text: String, on: Color, ghost: Color, modifier: Modifier = Modifier, fitTo: String = text) {
Canvas(modifier) {
val cells = text.map { cellWidth(it) }
val gap = 0.22f
val units = cells.sum() + gap * (text.length - 1).coerceAtLeast(0)
- val u = minOf(size.width / (units + SKEW * H), size.height / H)
+ // digits are sized for [fitTo] (or the text itself if that is wider), then the text is centred
+ val template = fitTo.sumOf { cellWidth(it).toDouble() }.toFloat() + gap * (fitTo.length - 1).coerceAtLeast(0)
+ val u = minOf(size.width / (maxOf(units, template) + SKEW * H), size.height / H)
var x = (size.width - units * u) / 2 - SKEW * H * u / 2
val y = (size.height - H * u) / 2
for ((i, ch) in text.withIndex()) {
diff --git a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt
new file mode 100644
index 0000000..d97626a
--- /dev/null
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/Svg.kt
@@ -0,0 +1,7 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.Density
+
+/** A painter for an SVG document, or null where this platform can't draw one. */
+expect fun svgPainter(svg: String, density: Density): Painter?
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 ed6f403..3d5115a 100644
--- a/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
+++ b/ui/src/commonMain/kotlin/coffee/liz/rookery/ui/TimerScreen.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
+import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +40,7 @@ import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@@ -46,7 +48,11 @@ import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coffee.liz.rookery.model.DNF
@@ -59,8 +65,6 @@ import coffee.liz.rookery.puzzle.Puzzles
import coffee.liz.rookery.stats.Averages
import coffee.liz.rookery.stats.StatKind
import coffee.liz.rookery.timer.TimerMachine.State
-import org.jetbrains.compose.resources.ExperimentalResourceApi
-import org.jetbrains.compose.resources.decodeToSvgPainter
@Composable
fun TimerScreen(m: AppModel) {
@@ -68,6 +72,7 @@ fun TimerScreen(m: AppModel) {
LaunchedEffect(ticking) { while (ticking) withFrameMillis { m.frame() } }
if (m.displayMode) { DisplayMode(m); return }
+ if (LocalNarrow.current) { NarrowTimer(m); return }
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Column(Modifier.width(280.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
SessionBox(m)
@@ -80,24 +85,51 @@ fun TimerScreen(m: AppModel) {
}
Column(Modifier.width(280.dp).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
StatsBox(m)
+ LiveCube(m)
PreviewBox(m)
SelectedSolveBox(m)
}
}
}
-@OptIn(ExperimentalLayoutApi::class)
+/** Phone layout: one line of session and averages, then the timer gets everything else. Solves and stats are in the footer. */
@Composable
-private fun SessionBox(m: AppModel) {
+private fun NarrowTimer(m: AppModel) {
var choosing by remember { mutableStateOf(false) }
+ val ss = m.derived.sessionStats
+ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroButton(m.derived.session?.name ?: "session", { choosing = !choosing }, selected = choosing, style = Rk.t.label, description = "choose session")
+ Spacer(Modifier.weight(1f))
+ val parts = listOf(StatKind.AO5, StatKind.AO12).mapNotNull { k -> ss.line(k)?.let { "${k.label} ${formatTime(it.current, true)}" } }
+ Label((parts + "${ss.count}").joinToString(" "), style = Rk.t.mono.copy(fontSize = 12.sp), color = Rk.c.inkDim, maxLines = 1)
+ }
+ if (choosing) { SessionBox(m) { choosing = false }; return@Column }
+ when (m.timerPane) {
+ "solves" -> { SolveTable(m, Modifier.weight(1f)); SelectedSolveBox(m) }
+ "stats" -> Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { StatsBox(m); LiveCube(m); PreviewBox(m) }
+ else -> {
+ ScrambleBox(m)
+ TimerDevice(m, Modifier.weight(1f).fillMaxWidth())
+ Row(Modifier.horizontalScroll(rememberScrollState())) { LastSolveLine(m) }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun SessionBox(m: AppModel, onPicked: () -> Unit = {}) {
+ var mode by remember { mutableStateOf("") } // "", "new", "rename", "delete"
+ val current = m.derived.session
GroupBox("sessions") {
- Column(Modifier.heightIn(max = 150.dp)) {
+ Column(Modifier.heightIn(max = 170.dp)) {
LazyColumn {
itemsIndexed(m.derived.sessions) { _, s ->
- val sel = s.id == m.derived.session?.id
+ val sel = s.id == current?.id
Row(
Modifier.fillMaxWidth().then(if (sel) Modifier.background(Rk.c.select) else Modifier)
- .clickable { m.selectSession(s.id) }.padding(horizontal = 4.dp, vertical = 2.dp),
+ .clickable { m.selectSession(s.id); mode = ""; onPicked() }.padding(horizontal = 4.dp, vertical = 3.dp),
) {
Label(s.name, Modifier.weight(1f), color = if (sel) Rk.c.selectInk else Rk.c.link, maxLines = 1)
Label(s.event.short, color = if (sel) Rk.c.selectInk else Rk.c.inkDim, style = Rk.t.label)
@@ -105,9 +137,27 @@ private fun SessionBox(m: AppModel) {
}
}
}
- 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)
+ Tabs(listOf("new", "rename", "delete"), mode, { it }, { mode = if (mode == it) "" else it }, style = Rk.t.label)
+ when (mode) {
+ "new" -> FlowRow {
+ for (e in Event.entries) RetroButton(e.short, { m.createSession(e); mode = ""; onPicked() }, description = "new ${e.title} session", style = Rk.t.label)
+ }
+ "rename" -> if (current != null) {
+ var name by remember(current.id) { mutableStateOf(current.name) }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RetroField(name, { name = it }, Modifier.weight(1f), onSubmit = { m.renameSession(current.id, name); mode = "" }, description = "session name")
+ RetroButton("save", { m.renameSession(current.id, name); mode = "" }, enabled = name.isNotBlank())
+ }
+ }
+ "delete" -> if (current != null) Column {
+ // the question wraps; the buttons stay short
+ Label("${current.name} and its ${m.derived.sessionSolves.size} solves?", color = Rk.c.bad)
+ Row {
+ RetroButton("yes, delete", { m.deleteSession(current.id); mode = "" })
+ Label(" · ", color = Rk.c.inkDim, modifier = Modifier.padding(vertical = 4.dp))
+ RetroButton("keep", { mode = "" })
+ }
+ }
}
}
}
@@ -152,10 +202,33 @@ private fun ScrambleBox(m: AppModel) {
RetroButton("next", { m.newScramble() })
}
val size = when { m.scramble.length > 300 -> 13.sp; m.scramble.length > 120 -> 16.sp; else -> 22.sp }
- BasicText(
- m.scramble, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = "scramble: ${m.scramble}" },
+ if (m.smartActive && m.smartKnown && m.timer !is State.Running) {
+ // following the scramble on the cube: what is left, next move first
+ val plan = m.smartPlan
+ // the sequence stays where it is; turned moves fade and the one in hand is pink
+ val text = buildAnnotatedString {
+ if (m.smartCorrected) withStyle(SpanStyle(color = Rk.c.gold, fontSize = size * 0.6f)) { append("fix ") }
+ for ((i, t) in m.smartSequence.withIndex()) {
+ if (i > 0) append(" ")
+ when {
+ i in m.smartDone -> withStyle(SpanStyle(color = Rk.c.inkDim.copy(alpha = 0.45f))) { append(t) }
+ i == m.smartCurrent -> withStyle(SpanStyle(color = Rk.c.accent, fontWeight = FontWeight.Bold)) { append(t) }
+ else -> append(t)
+ }
+ }
+ if (m.smartSequence.isEmpty()) withStyle(SpanStyle(color = Rk.c.good)) { append("scrambled") }
+ }
+ BasicText(
+ text, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = if (plan.isEmpty()) "scrambled" else "next ${plan[0]}, ${plan.size} moves left" },
+ style = Rk.t.mono.copy(fontSize = 22.sp, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = 30.sp),
+ )
+ } else BasicText(
+ m.scramble.ifEmpty { " " }, Modifier.fillMaxWidth().edge(Rk.c, sunk = true).padding(10.dp).semantics { contentDescription = "scramble: ${m.scramble}" },
style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.35f),
)
+ m.previousScramble?.let {
+ BasicText("previous $it", Modifier.fillMaxWidth().padding(horizontal = 6.dp), style = Rk.t.mono.copy(fontSize = 11.sp, color = Rk.c.inkDim), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
+ }
}
}
@@ -183,16 +256,23 @@ private fun inspectionText(elapsed: Long): String {
* fills whatever room it has, and the two lamps a mat has. Red is "hands down", green is "go".
*/
@Composable
-fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) {
+fun TimerDevice(m: AppModel, modifier: Modifier = Modifier, bleed: Boolean = false) {
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 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)
+ // With the mat as the timer, show the mat and nothing else, even through a gap in its signal.
+ val mat = m.stackmat.packet.takeIf { m.stackmat.enabled }
+ val running = mat?.running ?: (s is State.Running)
+ val raw = if (m.stackmat.enabled) (mat?.let { formatTime(it.ms) } ?: "0.00") else readout(s, m.now, last)
+ val text = when {
+ running && !m.runningHundredths -> raw.substringBefore('.') // whole seconds while the clock runs
+ Regex("^\\d\\.\\d\\d.*").matches(raw) -> "0$raw" // always a tens-of-seconds digit
+ else -> raw
+ }
val glass = Color(0xFFA9B7A1)
val ink = Color(0xFF1A2316)
Column(
- modifier.clip(RoundedCornerShape(18.dp)).background(Color(0xFF1B1D1C)).outline(Color(0xFF3A3D3B)).padding(14.dp)
- .then(if (!m.host.touchInput) Modifier else Modifier.pointerInput(Unit) {
+ modifier.clip(RoundedCornerShape(if (bleed) 0.dp else 18.dp)).background(Color(0xFF1B1D1C)).outline(Color(0xFF3A3D3B)).padding(if (bleed) 6.dp else 14.dp)
+ .then(if (!m.host.touchInput || m.stackmat.enabled) Modifier else Modifier.pointerInput(Unit) {
awaitEachGesture { awaitFirstDown(); m.press(); waitForUpOrCancellation(); m.release() }
}),
) {
@@ -200,14 +280,50 @@ fun TimerDevice(m: AppModel, modifier: Modifier = Modifier) {
Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(6.dp)).background(glass).outline(Color(0xFF0C0D0C), 2.dp).padding(horizontal = 22.dp, vertical = 14.dp)
.semantics { liveRegion = LiveRegionMode.Polite; contentDescription = "timer ${text.replace("dnF", "DNF")}" },
) {
- SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize())
+ // digits are sized for "00.00" whatever is showing, so 9 and 12 and 12.34 are all the same height
+ SevenSegment(text, ink, ink.copy(alpha = 0.06f), Modifier.fillMaxSize(), fitTo = "00.00")
+ if (m.smartRun && m.event.blind && s is State.Running) MemoWalker(memorizing = m.memoMs == null, still = m.reducedMotion, ink)
}
- Row(Modifier.fillMaxWidth().padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
+ Row(Modifier.fillMaxWidth().padding(top = if (bleed) 6.dp else 10.dp), verticalAlignment = Alignment.CenterVertically) {
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 = if (mat != null) mat.ready || mat.running else s is State.Ready || s is State.Running)
Spacer(Modifier.weight(1f))
- 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))
+ if (m.stackmat.enabled) BasicText(if (m.stackmat.live) "stackmat" else "stackmat: no signal", style = Rk.t.label.copy(color = Color(0xFF6A6F6C)))
+ }
+ }
+}
+
+/**
+ * Blind solves on a smart cube: while you memorise, a little LCD penguin paces along the top of the
+ * glass, the way a handheld game's one character would; when you start turning it stops and watches.
+ */
+@Composable
+private fun MemoWalker(memorizing: Boolean, still: Boolean, ink: Color) {
+ val sheet = penguinSheet()
+ var t by remember { mutableStateOf(0L) }
+ LaunchedEffect(memorizing, still) {
+ if (!memorizing || still) return@LaunchedEffect
+ val start = withFrameMillis { it }
+ while (true) t = withFrameMillis { it } - start
+ }
+ // draw the sprite as liquid crystal: dark where the penguin is dark, clear where it is white
+ val lcd = remember(ink) {
+ androidx.compose.ui.graphics.ColorFilter.colorMatrix(androidx.compose.ui.graphics.ColorMatrix(floatArrayOf(
+ 0f, 0f, 0f, 0f, ink.red * 255, 0f, 0f, 0f, 0f, ink.green * 255, 0f, 0f, 0f, 0f, ink.blue * 255, -0.4f, -0.4f, -0.4f, 1.2f, 0f,
+ )))
+ }
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ BasicText(if (memorizing) "MEMO" else "EXEC", style = Rk.t.mono.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = ink, letterSpacing = 2.sp))
+ Canvas(Modifier.weight(1f).padding(start = 12.dp).height(40.dp)) {
+ val sz = size.height
+ val span = (size.width - sz).coerceAtLeast(0f)
+ val lap = 5200L
+ val phase = (t % (2 * lap)).toFloat() / lap // 0..2: there and back
+ val x = if (!memorizing) 0f else if (phase < 1f) phase * span else (2f - phase) * span
+ val anim = if (!memorizing) PenguinAnim.ALERT else if (still) PenguinAnim.IDLE else if (phase < 1f) PenguinAnim.E else PenguinAnim.W
+ drawRect(ink.copy(alpha = 0.10f), Offset(0f, sz - 2.dp.toPx()), androidx.compose.ui.geometry.Size(size.width, 2.dp.toPx())) // the ground
+ drawPenguin(sheet, anim.frames[((t / 190) % anim.frames.size).toInt()], Offset(x, 0f), sz, colorFilter = lcd)
}
}
}
@@ -229,7 +345,8 @@ private fun LastSolveLine(m: AppModel) {
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", { m.deleteSolve(last.id) }, description = "delete last solve")
- Spacer(Modifier.weight(1f))
+ last.memoMs?.let { memo -> if (last.penalty != Penalty.DNF || last.ms > memo) Label(" memo ${formatTime(memo)} + exec ${formatTime(last.ms - memo)}", style = Rk.t.mono.copy(fontSize = 13.sp), color = Rk.c.inkDim) }
+ Spacer(Modifier.width(12.dp))
if (m.lastPbs.isNotEmpty()) Label("PB " + m.lastPbs.joinToString(", ") { it.kind.label }, color = Rk.c.accent, style = Rk.t.heading)
}
}
@@ -256,14 +373,35 @@ private fun StatsBox(m: AppModel) {
}
}
-@OptIn(ExperimentalResourceApi::class)
+/** The connected cube as it is now, held the way you hold it; null when there is none. */
+@Composable
+private fun liveCubeState(m: AppModel): coffee.liz.rookery.puzzle.PuzzleState? {
+ val f = m.liveFacelets?.takeIf { m.cube.connected != null && it.length == 54 } ?: return null
+ val mapping = remember { coffee.liz.rookery.puzzle.CubeMapping(Puzzles.cube(3), 3) }
+ val rotation = m.bld.setup.rotation
+ return remember(f, rotation) {
+ val c = coffee.liz.rookery.puzzle.NxnCube(3)
+ for (i in 0 until 54) c.stickers[i] = "URFDLB".indexOf(f[i]).coerceAtLeast(0)
+ mapping.state(c.apply(rotation).stickers)
+ }
+}
+
+@Composable
+private fun LiveCube(m: AppModel) {
+ val state = liveCubeState(m) ?: return
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ GroupBox("cube") {
+ PuzzleView(state, colors, Modifier.fillMaxWidth().height(200.dp), description = "your cube, live; drag to spin", draggable = true)
+ }
+}
+
@Composable
private fun PreviewBox(m: AppModel) {
m.replay?.let { ReplayBox(m, it); return }
val state = m.previewState
val svg = m.previewSvg
val density = LocalDensity.current
- val painter = remember(svg, density) { svg?.let { runCatching { it.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull() } }
+ val painter = remember(svg, density) { svg?.let { svgPainter(it, density) } }
if (state == null && painter == null) return
GroupBox("scramble") {
val colors = remember(m.event) { Puzzles.colors(m.event.puzzle).map(::rgb) }
@@ -312,15 +450,31 @@ private fun SelectedSolveBox(m: AppModel) {
@Composable
private fun DisplayMode(m: AppModel) {
val ss = m.derived.sessionStats
- Column(Modifier.fillMaxSize().padding(8.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- val size = if (m.scramble.length > 120) 18.sp else 28.sp
- BasicText(m.scramble, Modifier.fillMaxWidth(), style = Rk.t.mono.copy(fontSize = size, color = Rk.c.ink, textAlign = TextAlign.Center, lineHeight = size * 1.3f))
- TimerDevice(m, Modifier.weight(1f).fillMaxWidth())
- Row(verticalAlignment = Alignment.CenterVertically) {
- 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)
- RetroButton("exit", { m.changeDisplayMode(false) })
+ val cube = liveCubeState(m)
+ val colors = remember { Puzzles.colors(Puzzle.CUBE3).map(::rgb) }
+ androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
+ val landscape = maxWidth > maxHeight
+ val cubeWidth = maxWidth * 0.24f
+ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ // one line where there's width for it, shrinking the type to fit
+ BasicText(
+ m.scramble.ifEmpty { " " }, Modifier.fillMaxWidth().padding(horizontal = 8.dp),
+ style = Rk.t.mono.copy(color = Rk.c.ink, textAlign = TextAlign.Center),
+ maxLines = if (landscape) 1 else 2, softWrap = !landscape,
+ autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 9.sp, maxFontSize = 26.sp),
+ )
+ if (landscape) Row(Modifier.weight(1f).fillMaxWidth()) {
+ TimerDevice(m, Modifier.weight(1f).fillMaxHeight(), bleed = true)
+ if (cube != null) PuzzleView(cube, colors, Modifier.width(cubeWidth).fillMaxHeight(), description = "your cube, live", draggable = true)
+ } else {
+ TimerDevice(m, Modifier.weight(1f).fillMaxWidth(), bleed = true)
+ if (cube != null) PuzzleView(cube, colors, Modifier.fillMaxWidth().height(170.dp), description = "your cube, live", draggable = true)
+ }
+ Row(Modifier.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ 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}") + parts).joinToString(" · "), Modifier.weight(1f), style = Rk.t.mono.copy(fontSize = 14.sp), color = Rk.c.inkDim, maxLines = 1)
+ RetroButton("exit", { m.changeDisplayMode(false) }, style = Rk.t.label)
+ }
}
}
}