diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:28:48 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-11-13 15:37:09 -0800 |
| commit | 009525bd46edfc77068da212e36f38d410503f2c (patch) | |
| tree | f042565bb01d070f626213b72865800ec0dbf576 /ui/src/commonMain/kotlin | |
| parent | 60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff) | |
| download | rookery-009525bd46edfc77068da212e36f38d410503f2c.tar.gz rookery-009525bd46edfc77068da212e36f38d410503f2c.zip | |
Smart-cube timing, macOS Bluetooth, Android app, stackmat, pairing UI
- Timing with a smart cube: the scramble stays put and is marked off
move by move (ScrambleTracker); a stray turn gets the shortest path
to the same scrambled state from the two-phase solver, kept until
the next stray turn. Wide moves in BLD scrambles become face turns.
Once scrambled, inspection (or blind memo) starts by itself after a
configurable delay; the first turn starts the clock and the solved
cube stops it. The cube's own state reports are authoritative, so a
move lost over Bluetooth can't keep the timer running. Blind solves
show a MEMO/EXEC annunciator and store the memo split. A live view
of the cube sits beside the timer.
- Bluetooth through Kable on macOS and Windows (Linux keeps BlueZ) and
on Android; the cube's MAC is read from its advertisement where the
platform hides it. Scan/pair/connect/forget UI in settings.
- Android app (:android): core and ui gain Android targets, with a
jvmShared source set for code desktop and Android share. Android
host for storage, sound, file import, images, Bluetooth and audio.
Phone layouts: stacked panes and a full-screen timer with touch
start. Scramble drawings (SVG) are desktop-only for now.
- Stackmat: audio decoder and session; the mat can drive the timer.
- Timer readout always shows the tens-of-seconds digit.
- UI copy reduced to plain labels; marquee, snow, pointer-following
and penguin speech removed. Brave key can be entered in settings.
- Phone timer: one-line header, session picker and averages on one
line, solves/stats in the footer; sessions can be renamed and
deleted and are named <event>-<date>. Display mode fills the screen
(cut-out included), fits the scramble on one line, shows the live
cube, and the screen stays awake.
- Readout: digits keep one size (inspection included), whole seconds
while running unless set otherwise; previous scramble shown; the
scramble no longer blanks while the next is generated.
- Fixes: stackmat and smart cube no longer both drive the timer; a
solved cube or a sub-quarter-second tap can't record a solve.
- Blind memo indicator is an LCD penguin pacing the glass.
- Android: system-bar insets, folder picker (all files access), Brave
key synced with the data.
- Algorithm trainer: PLL and OLL from J Perm's sheets, each algorithm
verified by simulation; case plans, your choice of algorithm (or
your own, checked against the case), new/learning/known, and a
drill weighted towards weak cases.
- Pair cards show the pair on a 3D see-through cube with an arrow;
the memo cube can be dragged between targets.
- CI image installs the Android SDK and runs core tests + desktop
packaging (untested).
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Diffstat (limited to 'ui/src/commonMain/kotlin')
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) + } } } } |
