summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:28:48 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-11-13 15:37:09 -0800
commit009525bd46edfc77068da212e36f38d410503f2c (patch)
treef042565bb01d070f626213b72865800ec0dbf576
parent60426ce45763d0a4a5572db6926e8e677cbc0dc0 (diff)
downloadrookery-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>
-rw-r--r--Dockerfile9
-rw-r--r--android/build.gradle.kts30
-rw-r--r--android/src/main/AndroidManifest.xml25
-rw-r--r--android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt281
-rw-r--r--build.gradle.kts2
-rw-r--r--core/build.gradle.kts27
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt3
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt89
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt54
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt9
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt73
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt2
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt156
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt3
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt15
-rw-r--r--core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt20
-rw-r--r--core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt37
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt (renamed from core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt)1
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt (renamed from core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt)0
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt (renamed from core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt)8
-rw-r--r--core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt (renamed from core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt)0
-rw-r--r--core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt78
-rw-r--r--desktop/build.gradle.kts10
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt15
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt43
-rw-r--r--desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt58
-rw-r--r--gradle.properties2
-rw-r--r--gradle/libs.versions.toml11
-rw-r--r--settings.gradle.kts2
-rw-r--r--ui/build.gradle.kts7
-rw-r--r--ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt7
-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
-rw-r--r--ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt9
46 files changed, 1932 insertions, 152 deletions
diff --git a/Dockerfile b/Dockerfile
index b65f8e2..30c099e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,15 +1,22 @@
# Build + test stage: a failing test fails the image build, which is what lizCI checks.
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
+# Android SDK: core and ui have Android targets, so Gradle needs one even for the desktop build.
+ENV ANDROID_HOME=/opt/android-sdk
+RUN apt-get update && apt-get install -y --no-install-recommends unzip curl && rm -rf /var/lib/apt/lists/* \
+ && mkdir -p $ANDROID_HOME/cmdline-tools && curl -fsSL -o /tmp/clt.zip https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip \
+ && unzip -q /tmp/clt.zip -d $ANDROID_HOME/cmdline-tools && mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && rm /tmp/clt.zip \
+ && yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
# Warm the Gradle wrapper and dependency cache before copying sources.
COPY gradlew settings.gradle.kts build.gradle.kts gradle.properties ./
COPY gradle gradle
COPY core/build.gradle.kts core/
COPY ui/build.gradle.kts ui/
COPY desktop/build.gradle.kts desktop/
+COPY android/build.gradle.kts android/
RUN ./gradlew --no-daemon -q help
COPY . .
-RUN ./gradlew --no-daemon check :desktop:createDistributable
+RUN ./gradlew --no-daemon :core:jvmTest :desktop:createDistributable
# Artifact image: just the packaged Linux desktop app (with its bundled runtime).
FROM alpine:3.20 AS rookery
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..0c7509f
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,30 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "coffee.liz.rookery.android"
+ compileSdk = 37
+ defaultConfig {
+ applicationId = "coffee.liz.rookery"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+ buildFeatures { compose = true }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+ packaging { resources { excludes += setOf("META-INF/{AL2.0,LGPL2.1}", "META-INF/INDEX.LIST", "META-INF/DEPENDENCIES") } }
+}
+
+kotlin { jvmToolchain(21) }
+
+dependencies {
+ implementation(project(":ui"))
+ implementation(libs.activity.compose)
+ implementation(libs.coroutines.android)
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7266865
--- /dev/null
+++ b/android/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <!-- so the sync folder can be an ordinary shared folder that Syncthing also sees -->
+ <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
+ <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" />
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+ <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+ <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
+ <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
+ <uses-feature android:name="android.hardware.microphone" android:required="false" />
+
+ <application android:label="rookery" android:theme="@android:style/Theme.Black.NoTitleBar" android:allowBackup="true">
+ <activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize"
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|uiMode">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
diff --git a/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt
new file mode 100644
index 0000000..f4cde6c
--- /dev/null
+++ b/android/src/main/kotlin/coffee/liz/rookery/android/MainActivity.kt
@@ -0,0 +1,281 @@
+package coffee.liz.rookery.android
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.AudioTrack
+import android.media.MediaRecorder
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.ui.Modifier
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import coffee.liz.rookery.audio.Chime
+import coffee.liz.rookery.bt.BleAdvertisement
+import coffee.liz.rookery.bt.CubeBackend
+import coffee.liz.rookery.bt.GanCubeConnector
+import coffee.liz.rookery.bt.JvmAes128
+import coffee.liz.rookery.bt.KableTransport
+import coffee.liz.rookery.bt.SmartCube
+import coffee.liz.rookery.images.BraveImages
+import coffee.liz.rookery.images.ImageHit
+import coffee.liz.rookery.model.Event
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import coffee.liz.rookery.store.FolderBlobStore
+import coffee.liz.rookery.store.FolderLogStorage
+import coffee.liz.rookery.store.Repo
+import coffee.liz.rookery.store.Store
+import coffee.liz.rookery.ui.AppModel
+import coffee.liz.rookery.ui.Host
+import coffee.liz.rookery.ui.RookeryApp
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.UUID
+import java.util.zip.ZipFile
+
+class MainActivity : ComponentActivity() {
+ private var pendingFile: CompletableDeferred<Uri?>? = null
+ private val openDocument = registerForActivityResult(ActivityResultContracts.OpenDocument()) { pendingFile?.complete(it) }
+ private var pendingPermissions: CompletableDeferred<Boolean>? = null
+ private val askPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { r -> pendingPermissions?.complete(r.values.all { it }) }
+
+ private var pendingTree: CompletableDeferred<Uri?>? = null
+ private val openTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { pendingTree?.complete(it) }
+ private var pendingSettings: CompletableDeferred<Unit>? = null
+ private val openSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { pendingSettings?.complete(Unit) }
+
+ /**
+ * Choose a folder in shared storage and return it as a plain path. The data is ordinary files
+ * that a sync app must also reach, so this needs Android's "all files access", asked for first.
+ */
+ suspend fun pickFolder(): String? {
+ if (Build.VERSION.SDK_INT >= 30 && !android.os.Environment.isExternalStorageManager()) {
+ val d = CompletableDeferred<Unit>(); pendingSettings = d
+ val intent = android.content.Intent(android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))
+ runCatching { openSettings.launch(intent) }.onFailure { openSettings.launch(android.content.Intent(android.provider.Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) }
+ d.await()
+ if (!android.os.Environment.isExternalStorageManager()) return null
+ } else if (Build.VERSION.SDK_INT < 30 && !ensure(Manifest.permission.WRITE_EXTERNAL_STORAGE)) return null
+ val d = CompletableDeferred<Uri?>(); pendingTree = d
+ openTree.launch(null)
+ val uri = d.await() ?: return null
+ // tree ids look like "primary:Sync/rookery" or "1A2B-3C4D:Sync/rookery"
+ val id = android.provider.DocumentsContract.getTreeDocumentId(uri)
+ val volume = id.substringBefore(':'); val rel = id.substringAfter(':', "")
+ val base = if (volume.equals("primary", true)) android.os.Environment.getExternalStorageDirectory() else File("/storage/$volume")
+ return File(base, rel).path
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val model = holder ?: run {
+ val host = AndroidHost(applicationContext)
+ val root = host.root.toPath()
+ val store = Store(host.deviceId, FolderLogStorage(root, host.deviceId), FolderBlobStore(root), System::currentTimeMillis)
+ AppModel(Repo(store), TnoodleScrambler().apply { warm(Event.E333) }, host, MainScope()).also { holder = it }
+ }
+ (model.host as AndroidHost).activity = this
+ enableEdgeToEdge()
+ window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // a timer mustn't go dark mid-solve
+ setContent { RookeryApp(model, Modifier.windowInsetsPadding(WindowInsets.safeDrawing)) }
+ }
+
+ override fun onDestroy() {
+ (holder?.host as? AndroidHost)?.let { if (it.activity === this) it.activity = null }
+ super.onDestroy()
+ }
+
+ suspend fun pick(mimeTypes: Array<String>): Uri? {
+ val d = CompletableDeferred<Uri?>(); pendingFile = d
+ openDocument.launch(mimeTypes)
+ return d.await()
+ }
+
+ suspend fun ensure(vararg permissions: String): Boolean {
+ val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
+ if (missing.isEmpty()) return true
+ val d = CompletableDeferred<Boolean>(); pendingPermissions = d
+ askPermissions.launch(missing.toTypedArray())
+ return d.await()
+ }
+
+ fun fullscreen(on: Boolean) {
+ val c = WindowInsetsControllerCompat(window, window.decorView)
+ if (on) { c.hide(WindowInsetsCompat.Type.systemBars()); c.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE }
+ else c.show(WindowInsetsCompat.Type.systemBars())
+ }
+
+ companion object { private var holder: AppModel? = null } // survives the activity being recreated
+}
+
+/** What rookery needs from Android. */
+class AndroidHost(private val context: Context) : Host {
+ var activity: MainActivity? = null
+ private val prefs = context.getSharedPreferences("local", Context.MODE_PRIVATE)
+
+ /** All data: app storage that file managers (and sync apps given access) can see, else private storage. */
+ val root: File get() {
+ val own = (context.getExternalFilesDir(null) ?: context.filesDir).resolve("sync")
+ val chosen = localPref("syncFolder")?.let(::File)
+ // fall back to the app's own folder if the chosen one can't be written (permission withdrawn, card removed)
+ return (chosen?.takeIf { (it.isDirectory || it.mkdirs()) && it.canWrite() } ?: own).apply { mkdirs() }
+ }
+
+ override val deviceId: String = prefs.getString("deviceId", null)
+ ?: "${Build.MODEL.filter { it.isLetterOrDigit() }.take(16).lowercase()}-${UUID.randomUUID().toString().take(6)}".also { prefs.edit().putString("deviceId", it).apply() }
+ override val syncFolder: String get() = root.path
+ override fun setSyncFolder(path: String) = setLocalPref("syncFolder", path)
+ override fun localPref(key: String): String? = prefs.getString("local.$key", null)
+ override fun setLocalPref(key: String, value: String?) { prefs.edit().apply { if (value == null) remove("local.$key") else putString("local.$key", value) }.apply() }
+ override val braveApiKey: String? get() = localPref("braveApiKey")?.takeIf { it.isNotBlank() }
+ override fun setBraveApiKey(key: String?) = setLocalPref("braveApiKey", key)
+ override val touchInput: Boolean get() = true
+
+ override fun playChime(notes: List<Chime.Note>) {
+ Thread {
+ runCatching {
+ val pcm = Chime.render(notes)
+ val track = AudioTrack.Builder()
+ .setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_GAME).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
+ .setAudioFormat(AudioFormat.Builder().setEncoding(AudioFormat.ENCODING_PCM_16BIT).setSampleRate(Chime.SAMPLE_RATE).setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build())
+ .setBufferSizeInBytes(pcm.size * 2).setTransferMode(AudioTrack.MODE_STATIC).build()
+ track.write(pcm, 0, pcm.size); track.play()
+ Thread.sleep(pcm.size * 1000L / Chime.SAMPLE_RATE + 200)
+ track.release()
+ }
+ }.apply { isDaemon = true }.start()
+ }
+
+ /** Copies the chosen document into the cache and returns that path, so the rest of the app can use plain files. */
+ override suspend fun pickFile(title: String, extensions: List<String>): String? {
+ val uri = activity?.pick(arrayOf("*/*")) ?: return null
+ return withContext(Dispatchers.IO) {
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> if (c.moveToFirst()) c.getString(0) else null } ?: "import"
+ val out = File(context.cacheDir, "picked").apply { mkdirs() }.resolve(name.replace('/', '_'))
+ context.contentResolver.openInputStream(uri)?.use { i -> out.outputStream().use { i.copyTo(it) } } ?: return@withContext null
+ out.path
+ }
+ }
+
+ override suspend fun pickFolder(title: String): String? = activity?.pickFolder()
+ override suspend fun readFile(path: String): ByteArray = withContext(Dispatchers.IO) { File(path).readBytes() }
+
+ override suspend fun readImport(path: String, mainEntry: String?): Pair<String, (String) -> ByteArray?> = withContext(Dispatchers.IO) {
+ if (!path.lowercase().endsWith(".zip")) File(path).readText() to { _: String -> null }
+ else {
+ val entries = ZipFile(path).use { z -> z.entries().toList().filter { !it.isDirectory }.associate { it.name to z.getInputStream(it).readBytes() } }
+ val main = entries.entries.firstOrNull { it.key == mainEntry || it.key.endsWith("/$mainEntry") } ?: error("no $mainEntry in the zip")
+ val prefix = main.key.removeSuffix(mainEntry ?: "")
+ main.value.decodeToString() to { rel: String -> entries[prefix + rel] }
+ }
+ }
+
+ private fun get(url: String, headers: Map<String, String> = emptyMap()): ByteArray {
+ require(url.startsWith("https://")) { "refusing a non-https address" }
+ val c = (URL(url).openConnection() as HttpURLConnection).apply {
+ connectTimeout = 10_000; readTimeout = 20_000; setRequestProperty("User-Agent", "rookery")
+ headers.forEach { (k, v) -> setRequestProperty(k, v) }
+ }
+ try {
+ if (c.responseCode !in 200..299) error("the server said ${c.responseCode}")
+ val out = ByteArrayOutputStream(); val buf = ByteArray(16384)
+ c.inputStream.use { i -> while (true) { val n = i.read(buf); if (n < 0) break; out.write(buf, 0, n); check(out.size() <= 12 * 1024 * 1024) { "that file is too large" } } }
+ return out.toByteArray()
+ } finally { c.disconnect() }
+ }
+
+ override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) {
+ BraveImages.parse(get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString())
+ }
+
+ override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) }
+
+ /** Picture bytes from the clipboard, which other apps leave as a content uri. */
+ override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) {
+ val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ val clip = runCatching { cm.primaryClip }.getOrNull() ?: return@withContext null
+ if (clip.description?.hasMimeType("image/*") != true) return@withContext null
+ val uri = clip.getItemAt(0)?.uri ?: return@withContext null
+ withContext(Dispatchers.IO) { runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull() }
+ }
+
+ override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching {
+ val src = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
+ val k = minOf(1f, 640f / maxOf(src.width, src.height))
+ val scaled = if (k < 1f) Bitmap.createScaledBitmap(src, (src.width * k).toInt().coerceAtLeast(1), (src.height * k).toInt().coerceAtLeast(1), true) else src
+ val out = ByteArrayOutputStream()
+ @Suppress("DEPRECATION")
+ scaled.compress(if (Build.VERSION.SDK_INT >= 30) Bitmap.CompressFormat.WEBP_LOSSY else Bitmap.CompressFormat.WEBP, 82, out)
+ out.toByteArray() to "webp"
+ }.getOrNull()
+
+ override fun setFullscreen(on: Boolean) { activity?.fullscreen(on) }
+
+ override val cubes: CubeBackend = object : CubeBackend {
+ private val transport by lazy { KableTransport() }
+ private suspend fun allowed(): Boolean {
+ val a = activity ?: return false
+ return if (Build.VERSION.SDK_INT >= 31) a.ensure(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else a.ensure(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ override fun scan(): Flow<BleAdvertisement> = flow {
+ if (!allowed()) error("Bluetooth permission was not granted")
+ emitAll(transport.scan().filter { GanCubeConnector.isGanCube(it) })
+ }
+ override suspend fun connect(scope: CoroutineScope, address: String, mac: String): SmartCube {
+ if (!allowed()) error("Bluetooth permission was not granted")
+ return GanCubeConnector(transport, JvmAes128).connect(scope, address, mac)
+ }
+ }
+
+ override fun audioInputs(): List<String> = emptyList()
+ override val audioSampleRate: Int get() = 44_100
+
+ @android.annotation.SuppressLint("MissingPermission")
+ override fun openAudio(input: String?, onSamples: (FloatArray, Int) -> Unit): AutoCloseable {
+ if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
+ activity?.let { a -> MainScope().launch { a.ensure(Manifest.permission.RECORD_AUDIO) } }
+ error("allow the microphone, then switch the stackmat on again")
+ }
+ val size = maxOf(AudioRecord.getMinBufferSize(44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT), 8192)
+ // UNPROCESSED where available: the timer's signal must not be "cleaned up" as if it were speech
+ val rec = AudioRecord(MediaRecorder.AudioSource.UNPROCESSED, 44_100, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, size)
+ check(rec.state == AudioRecord.STATE_INITIALIZED) { "couldn't open the audio input" }
+ rec.startRecording()
+ val thread = Thread {
+ val buf = ShortArray(1024); val samples = FloatArray(1024)
+ while (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
+ val n = rec.read(buf, 0, buf.size)
+ if (n <= 0) break
+ for (i in 0 until n) samples[i] = buf[i] / 32768f
+ onSamples(samples, n)
+ }
+ }.apply { isDaemon = true; name = "stackmat-audio"; start() }
+ return AutoCloseable { runCatching { rec.stop() }; rec.release(); thread.interrupt() }
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 1efb0bc..1887745 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,4 +4,6 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.compose) apply false
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.kmp.library) apply false
}
diff --git a/core/build.gradle.kts b/core/build.gradle.kts
index 718fdaa..1dcf4db 100644
--- a/core/build.gradle.kts
+++ b/core/build.gradle.kts
@@ -1,26 +1,45 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.android.kmp.library)
}
kotlin {
jvmToolchain(21)
jvm()
+ androidLibrary {
+ namespace = "coffee.liz.rookery.core"
+ compileSdk = 37
+ minSdk = 26
+ }
sourceSets {
commonMain.dependencies {
implementation(libs.coroutines.core)
api(libs.serialization.json)
+ implementation(libs.kable) // Bluetooth LE on desktop (macOS, Windows, Linux) and Android
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.coroutines.test)
}
- jvmMain.dependencies {
- implementation(libs.tnoodle.scrambles)
- implementation(libs.dbus.java.core)
- implementation(libs.dbus.java.transport.native)
+ // Plain-JVM code that desktop and Android share: files, tnoodle, AES.
+ val jvmShared by creating {
+ dependsOn(commonMain.get())
+ dependencies {
+ implementation(libs.tnoodle.scrambles)
+ implementation(libs.tnoodle.min2phase) // two-phase solver, for smart-cube scramble corrections
+ }
+ }
+ jvmMain {
+ dependsOn(jvmShared)
+ dependencies {
+ // BlueZ over D-Bus: Linux desktop only
+ implementation(libs.dbus.java.core)
+ implementation(libs.dbus.java.transport.native)
+ }
}
+ androidMain { dependsOn(jvmShared) }
}
}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt
index 954e423..efcb3c0 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/Platform.kt
@@ -9,5 +9,8 @@ expect fun monoMillis(): Long
/** A random identifier, unique enough to name devices and new entities. */
expect fun randomId(): String
+/** Today's date here, as yyyy-mm-dd. */
+expect fun today(): String
+
/** Mutual exclusion on [lock] (reentrant). */
expect fun <T> locked(lock: Any, block: () -> T): T
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt
new file mode 100644
index 0000000..b12c033
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgData.kt
@@ -0,0 +1,89 @@
+package coffee.liz.rookery.algs
+
+// Case names, groups and algorithms as listed on jperm.net/algs (J Perm's algorithm sheets).
+// Generated from that site's data; edit AlgSets.kt for behaviour, not this file.
+
+internal val ALG_DATA: Map<String, List<AlgCase>> = mapOf(
+ "pll" to listOf(
+ AlgCase("pll", "H", "Edges Only", 1, listOf("M2 U M2 U2 M2 U M2", "M2 U' M2 U2 M2 U' M2")),
+ AlgCase("pll", "Z", "Edges Only", 2, listOf("M' U M2 U M2 U M' U2 M2", "y M' U' M2 U' M2 U' M' U2 M2", "y M2 U M2 U M' U2 M2 U2 M'", "M2 U' M2 U' M' U2 M2 U2 M'")),
+ AlgCase("pll", "Ua", "Edges Only", 4, listOf("M2 U M U2 M' U M2", "R U' R U R U R U' R' U' R2", "y2 R2 U' R' U' R U R U R U' R")),
+ AlgCase("pll", "Ub", "Edges Only", 4, listOf("M2 U' M U2 M' U' M2", "R2 U (R U R' U') R' U' R' U R'", "y2 R' U R' U' R' U' (R' U R U) R2", "y2 R' U R' U' R3 U' (R' U R U) R2")),
+ AlgCase("pll", "Aa", "Adjacent Corner Swap", 4, listOf("x L2 D2 L' U' L D2 L' U L'", "y' x' L' U L' D2 L U' L' D2 L2", "y x R' U R' D2 R U' R' D2 R2", "y2 x' R2 D2 R' U' R D2 R' U R'")),
+ AlgCase("pll", "Ab", "Adjacent Corner Swap", 4, listOf("x' L2 D2 L U L' D2 L U' L", "y x L U' L D2 L' U L D2 L2", "y2 x R2 D2 R U R' D2 R U' R", "y' x' R U' R D2 R' U R D2 R2")),
+ AlgCase("pll", "E", "Diagonal Corner Swap", 2, listOf("x' L' U L D' L' U' L D L' U' L D' L' U L D", "x' R U' R' D R U R' D' R U R' D R U' R' D'")),
+ AlgCase("pll", "F", "Adjacent Corner Swap", 4, listOf("R' U' F' R U R' U' R' F R2 U' R' U' R U R' U R")),
+ AlgCase("pll", "Ja", "Adjacent Corner Swap", 4, listOf("x R2 F R F' R U2 r' U r U2", "y2 L' U' L F L' U' L U L F' L2 U L", "y' R' U L' U2 R U' R' U2 R L")),
+ AlgCase("pll", "Jb", "Adjacent Corner Swap", 4, listOf("R U R' F' R U R' U' R' F R2 U' R'")),
+ AlgCase("pll", "Ra", "Adjacent Corner Swap", 4, listOf("R U' R' U' R U R D R' U' R D' R' U2 R'", "R U R' F' R U2 R' U2 R' F R U R U2 R'", "y' L U2 L' U2 L F' L' U' L U L F L2")),
+ AlgCase("pll", "Rb", "Adjacent Corner Swap", 4, listOf("R2 F R U R U' R' F' R U2 R' U2 R", "y' R' U2 R U2 R' F R U R' U' R' F' R2", "R' U2 R' D' R U' R' D R U R U' R' U' R")),
+ AlgCase("pll", "T", "Adjacent Corner Swap", 4, listOf("R U R' U' R' F R2 U' R' U' (R U R') F'")),
+ AlgCase("pll", "Y", "Diagonal Corner Swap", 4, listOf("F R U' R' U' R U R' F' R U R' U' R' F R F'", "F R' F R2 U' R' U' R U R' F' R U R' U' F'")),
+ AlgCase("pll", "V", "Diagonal Corner Swap", 4, listOf("R' U R' U' y R' F' R2 U' R' U R' F R F", "R' U R' U' R D' R' D R' U D' R2 U' R2 D R2", "z D' R2 D R2 U R' D' R U' R U R' D R U' z'", "R U2 R' D R U' R U' R U R2 D R' U' R D2", "x' R' F R F' U R U2 R' U' R U' R' U2 R U R' U'")),
+ AlgCase("pll", "Na", "Diagonal Corner Swap", 1, listOf("R U R' U R U R' F' R U R' U' R' F R2 U' R' U2 R U' R'", "z U R' D R2 U' R D' U R' D R2 U' R D'")),
+ AlgCase("pll", "Nb", "Diagonal Corner Swap", 1, listOf("R' (U R U' R') F' U' F R U R' F R' F' R U' R", "z D' R U' R2 D R' U D' R U' R2 D R' U")),
+ AlgCase("pll", "Ga", "Adjacent Corner Swap", 4, listOf("R2 U R' U R' U' R U' R2 (U' D) R' U R D'", "R2 u R' U R' U' R u' R2 y' R' U R")),
+ AlgCase("pll", "Gb", "Adjacent Corner Swap", 4, listOf("R' U' R (U D') R2 U R' U R U' R U' R2 D", "y F' U' F R2 u R' U R U' R u' R2")),
+ AlgCase("pll", "Gc", "Adjacent Corner Swap", 4, listOf("R2 U' R U' R U R' U R2 (U D') R U' R' D", "y2 R2 F2 R U2 R U2 R' F R U R' U' R' F R2", "R2 u' R U' R U R' u R2 y R U' R'")),
+ AlgCase("pll", "Gd", "Adjacent Corner Swap", 4, listOf("R U R' (U' D) R2 U' R U' R' U R' U R2 D'", "R U R' y' R2 u' R U' R' U R' u R2")),
+ ),
+ "oll" to listOf(
+ AlgCase("oll", "1", "Dot", 2, listOf("R U2 R' R' F R F' U2 R' F R F'")),
+ AlgCase("oll", "2", "Dot", 4, listOf("r U r' U2 r U2 R' U2 R U' r'", "y' F R U R' U' F' f R U R' U' f'", "y' F R U R' U' S R U R' U' f'")),
+ AlgCase("oll", "3", "Dot", 4, listOf("r' R2 U R' U r U2 r' U M'", "y F U R U' R' F' U F R U R' U' F'", "y' f R U R' U' f' U' F R U R' U' F'")),
+ AlgCase("oll", "4", "Dot", 4, listOf("M U' r U2 r' U' R U' R' M'", "y F U R U' R' F' U' F R U R' U' F'", "y' f R U R' U' f' U F R U R' U' F'")),
+ AlgCase("oll", "5", "Square Shape", 4, listOf("l' U2 L U L' U l", "y2 r' U2 R U R' U r")),
+ AlgCase("oll", "6", "Square Shape", 4, listOf("r U2 R' U' R U' r'")),
+ AlgCase("oll", "7", "Small Lightning Bolt", 4, listOf("r U R' U R U2 r'")),
+ AlgCase("oll", "8", "Small Lightning Bolt", 4, listOf("l' U' L U' L' U2 l", "R U2 R' U2 R' F R F'", "y2 r' U' R U' R' U2 r")),
+ AlgCase("oll", "9", "Fish Shape", 4, listOf("R U R' U' R' F R2 U R' U' F'")),
+ AlgCase("oll", "10", "Fish Shape", 4, listOf("R U R' U R' F R F' R U2 R'", "y2 r U R' U R U' R' U' r' R (U R U' R')")),
+ AlgCase("oll", "11", "Small Lightning Bolt", 4, listOf("r U R' U R' F R F' R U2 r'", "y2 r' R2 U R' U R U2 R' U M'")),
+ AlgCase("oll", "12", "Small Lightning Bolt", 4, listOf("M' R' U' R U' R' U2 R U' R r'")),
+ AlgCase("oll", "13", "Knight Move Shape", 4, listOf("F U R U' R2 F' R U R U' R'", "r U' r' U' r U r' y' R' U R")),
+ AlgCase("oll", "14", "Knight Move Shape", 4, listOf("R' F R U R' F' R F U' F'")),
+ AlgCase("oll", "15", "Knight Move Shape", 4, listOf("l' U' l L' U' L U l' U l", "y2 r' U' r R' U' R U r' U r")),
+ AlgCase("oll", "16", "Knight Move Shape", 4, listOf("r U r' R U R' U' r U' r'")),
+ AlgCase("oll", "17", "Dot", 4, listOf("F R' F' R2 r' U R U' R' U' M'", "y2 R U R' U R' F R F' U2 R' F R F'")),
+ AlgCase("oll", "18", "Dot", 4, listOf("r U R' U R U2 r' r' U' R U' R' U2 r", "y R U2 R' R' F R F' U2 M' (U R U' r')")),
+ AlgCase("oll", "19", "Dot", 4, listOf("r' R U R U R' U' M' R' F R F'")),
+ AlgCase("oll", "20", "Dot", 1, listOf("r U R' U' M2 U R U' R' U' M'", "r' R U (R U R' U') M2 U R U' r'")),
+ AlgCase("oll", "21", "Cross", 2, listOf("R U2 R' U' R U R' U' R U' R'", "y R U R' U R U' R' U R U2 R'")),
+ AlgCase("oll", "22", "Cross", 4, listOf("R U2 (R2 U' R2 U' R2) U2 R")),
+ AlgCase("oll", "23", "Cross", 4, listOf("R2 D' R U2 R' D R U2 R", "y2 R2 D R' U2 R D' R' U2 R'")),
+ AlgCase("oll", "24", "Cross", 4, listOf("r U R' U' r' F R F'", "y R U R D R' U' R D' R2")),
+ AlgCase("oll", "25", "Cross", 4, listOf("F' r U R' U' r' F R", "y' R' F R B' R' F' R B")),
+ AlgCase("oll", "26", "Cross", 4, listOf("(R U2 R') U' R U' R'", "y' R' U' R U' R' U2 R")),
+ AlgCase("oll", "27", "Cross", 4, listOf("R U R' U R U2 R'", "y' R' U2 (R U R' U) R")),
+ AlgCase("oll", "28", "Corners Oriented", 4, listOf("r U R' U' r' R U R U' R'")),
+ AlgCase("oll", "29", "Awkward Shape", 4, listOf("R U R' U' R U' R' F' U' F R U R'")),
+ AlgCase("oll", "30", "Awkward Shape", 4, listOf("F R' F R2 U' R' U' R U R' F2", "F U (R U2 R' U') R U2 R' U' F'")),
+ AlgCase("oll", "31", "P Shape", 4, listOf("R' U' F U R U' R' F' R")),
+ AlgCase("oll", "32", "P Shape", 4, listOf("L U F' U' L' U L F L'", "y2 S R U R' U' R' F R f'")),
+ AlgCase("oll", "33", "T Shape", 4, listOf("R U R' U' R' F R F'")),
+ AlgCase("oll", "34", "C Shape", 4, listOf("R U R2 U' R' F R U R U' F'", "R U R' U' B' R' F R F' B")),
+ AlgCase("oll", "35", "Fish Shape", 4, listOf("R U2 R' R' F R F' R U2 R'")),
+ AlgCase("oll", "36", "W Shape", 4, listOf("L' U' L U' L' U L U L F' L' F", "y2 R' U' R U' R' U R U R B' R' B")),
+ AlgCase("oll", "37", "Fish Shape", 4, listOf("F R' F' R U R U' R'", "F R U' R' U' R U R' F'")),
+ AlgCase("oll", "38", "W Shape", 4, listOf("R U R' U R U' R' U' R' F R F'")),
+ AlgCase("oll", "39", "Big Lightning Bolt", 4, listOf("L F' L' U' L U F U' L'", "y2 R B' R' U' R U B U' R'")),
+ AlgCase("oll", "40", "Big Lightning Bolt", 4, listOf("R' F R U R' U' F' U R")),
+ AlgCase("oll", "41", "Awkward Shape", 4, listOf("R U R' U R U2 R' F R U R' U' F'")),
+ AlgCase("oll", "42", "Awkward Shape", 4, listOf("R' U' R U' R' U2 R F R U R' U' F'")),
+ AlgCase("oll", "43", "P Shape", 4, listOf("F' U' L' U L F", "R' U' F R' F' R U R")),
+ AlgCase("oll", "44", "P Shape", 4, listOf("F U R U' R' F'", "y2 f R U R' U' f'")),
+ AlgCase("oll", "45", "T Shape", 4, listOf("F R U R' U' F'")),
+ AlgCase("oll", "46", "C Shape", 4, listOf("R' U' R' F R F' U R")),
+ AlgCase("oll", "47", "Small L Shape", 4, listOf("R' U' R' F R F' R' F R F' U R", "F' L' U' L U L' U' L U F", "y' F U R U' R' F' R U R' U R U2 R'")),
+ AlgCase("oll", "48", "Small L Shape", 4, listOf("F R U R' U' R U R' U' F'")),
+ AlgCase("oll", "49", "Small L Shape", 4, listOf("r U' r2 U r2 U r2 U' r")),
+ AlgCase("oll", "50", "Small L Shape", 4, listOf("r' U r2 U' r2 U' r2 U r'")),
+ AlgCase("oll", "51", "I Shape", 4, listOf("F U R U' R' U R U' R' F'", "y2 f R U R' U' R U R' U' f'")),
+ AlgCase("oll", "52", "I Shape", 4, listOf("R U R' U R U' B U' B' R'", "y2 R' F' U' F U' (R U R' U) R", "R U R' U R U' y R U' R' F'")),
+ AlgCase("oll", "53", "Small L Shape", 4, listOf("l' U2 L U L' U' L U L' U l", "y2 r' U2 (R U R' U') R U R' U r", "y r' U' R U' R' U R U' R' U2 r")),
+ AlgCase("oll", "54", "Small L Shape", 4, listOf("(r U2 R' U') R U R' U' R U' r'", "y r U R' U R U' R' U R U2 r'")),
+ AlgCase("oll", "55", "I Shape", 2, listOf("R' F R U R U' R2 F' R2 U' R' U R U R'", "y R U2 R2 U' R U' R' U2 F R F'")),
+ AlgCase("oll", "56", "I Shape", 2, listOf("(r' U' r) U' R' U R U' R' U R r' U r", "(r U r') U R U' R' U R U' R' (r U' r')", "(r U r') U R U' R' U R U' M' U' r'")),
+ AlgCase("oll", "57", "Corners Oriented", 2, listOf("R U R' U' M' U R U' r'")),
+ ),
+)
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt
new file mode 100644
index 0000000..2309d97
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/algs/AlgSets.kt
@@ -0,0 +1,54 @@
+package coffee.liz.rookery.algs
+
+import coffee.liz.rookery.puzzle.Notation
+import coffee.liz.rookery.puzzle.NxnCube
+
+/** One case of an algorithm set, with the algorithms people use for it (the first is the usual one). */
+data class AlgCase(val set: String, val name: String, val group: String, val probability: Int, val algs: List<String>) {
+ val id: String get() = "$set:$name"
+
+ /**
+ * The cube showing this case, last layer up, as you would hold it before doing [alg]: a solved
+ * cube with the algorithm undone. An algorithm with a net rotation (say it starts with `x`)
+ * leaves the centres somewhere else, so colours are renamed after whichever centre they sit on.
+ */
+ fun state(alg: String = algs.first()): NxnCube {
+ val c = NxnCube(3).apply(Notation.invert(clean(alg)))
+ val rename = IntArray(6)
+ for (f in 0 until 6) rename[c.stickers[f * 9 + 4]] = f
+ for (i in c.stickers.indices) c.stickers[i] = rename[c.stickers[i]]
+ return c
+ }
+
+ /** Moves that set this case up from a solved cube. */
+ fun setup(alg: String = algs.first()): String = Notation.invert(clean(alg))
+
+ companion object {
+ fun clean(alg: String) = alg.replace("(", " ").replace(")", " ").trim().replace(Regex("\\s+"), " ")
+ }
+}
+
+enum class AlgStatus { NEW, LEARNING, KNOWN }
+
+object AlgSets {
+ val names: List<String> get() = ALG_DATA.keys.toList()
+ fun cases(set: String): List<AlgCase> = ALG_DATA[set].orEmpty()
+ fun find(id: String): AlgCase? = cases(id.substringBefore(':')).firstOrNull { it.id == id }
+
+ /**
+ * The last layer seen from above, as 5x5 cells of colour indices (-1 = empty corner): the U face
+ * in the middle and the top row of each side around it, front at the bottom.
+ */
+ fun plan(c: NxnCube): Array<IntArray> {
+ val s = c.stickers
+ val g = Array(5) { IntArray(5) { -1 } }
+ for (r in 0 until 3) for (k in 0 until 3) g[r + 1][k + 1] = s[r * 3 + k] // U
+ for (k in 0 until 3) {
+ g[4][k + 1] = s[2 * 9 + k] // F, left to right
+ g[0][k + 1] = s[5 * 9 + (2 - k)] // B, seen from above
+ g[k + 1][4] = s[1 * 9 + (2 - k)] // R, back to front
+ g[k + 1][0] = s[4 * 9 + k] // L, back to front
+ }
+ return g
+ }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt
index f27079d..614cc53 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bld/Bld.kt
@@ -67,6 +67,15 @@ object Cube3 {
return Triple(pick(c.first, prefer.first), pick(c.second, prefer.second), pick(c.third, prefer.third))
}
+ /** The corner view (signs along x, y, z) from which the most of [slots] can be seen, preferring the usual front-top-right. */
+ fun bestView(slots: List<Int>): Triple<Int, Int, Int> {
+ val corners = listOf(1, -1).flatMap { x -> listOf(1, -1).flatMap { y -> listOf(1, -1).map { z -> Triple(x, y, z) } } }
+ return corners.maxBy { c ->
+ slots.count { s -> val n = geo.normalOf(s); n.first * c.first + n.second * c.second + n.third * c.third > 0 } * 8 +
+ (if (c.second > 0) 4 else 0) + (if (c.third > 0) 2 else 0) + (if (c.first > 0) 1 else 0)
+ }
+ }
+
/** The piece's lowest position, as a stable id for the piece. */
fun pieceId(type: PieceType, pos: Int): Int = pieceFrom(type, pos).min()
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt
new file mode 100644
index 0000000..9c36517
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/KableTransport.kt
@@ -0,0 +1,73 @@
+@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
+
+package coffee.liz.rookery.bt
+
+import com.juul.kable.Peripheral
+import com.juul.kable.PlatformAdvertisement
+import com.juul.kable.Scanner
+import com.juul.kable.State
+import com.juul.kable.WriteType
+import com.juul.kable.characteristicOf
+import com.juul.kable.write
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * Bluetooth LE through Kable, which speaks CoreBluetooth on macOS, WinRT on Windows, BlueZ on Linux
+ * and the platform stack on Android, behind one API.
+ *
+ * Addresses are whatever the platform uses to name a device: a MAC on Linux, Windows and Android,
+ * an opaque UUID on macOS. Callers that need the real MAC (GAN cubes do) must get it elsewhere.
+ */
+class KableTransport : BleTransport {
+ private val seen = HashMap<String, PlatformAdvertisement>()
+
+ override fun scan(): Flow<BleAdvertisement> = Scanner().advertisements.map { a ->
+ val id = a.identifier.toString()
+ synchronized(seen) { seen[id] = a }
+ BleAdvertisement(
+ address = id, name = a.name ?: a.peripheralName, rssi = a.rssi,
+ manufacturerData = a.manufacturerData?.let { mapOf(it.code to it.data) } ?: emptyMap(),
+ serviceUuids = a.uuids.map { it.toString() },
+ )
+ }
+
+ override suspend fun connect(address: String): BleConnection {
+ // Peripherals are made from an advertisement, so one must have been heard (now or just before).
+ val adv = synchronized(seen) { seen[address] }
+ ?: withTimeoutOrNull(12_000) { Scanner().advertisements.first { it.identifier.toString() == address } }
+ ?: error("can't hear that cube. turn it to wake it, then try again")
+ val peripheral = Peripheral(adv)
+ try { peripheral.connect() } catch (e: Exception) { runCatching { peripheral.close() }; throw e }
+ return KableConnection(peripheral, address, adv.name ?: adv.peripheralName)
+ }
+
+ private fun <T> synchronized(lock: Any, block: () -> T): T = coffee.liz.rookery.locked(lock, block)
+}
+
+private fun char(service: String, characteristic: String) = characteristicOf(kotlin.uuid.Uuid.parse(service), kotlin.uuid.Uuid.parse(characteristic))
+
+private class KableConnection(private val p: Peripheral, override val address: String, override val name: String?) : BleConnection {
+ override suspend fun discoverServices(): List<BleService> = p.services.value.orEmpty().map { s ->
+ BleService(s.serviceUuid.toString(), s.characteristics.map { BleCharacteristic(it.characteristicUuid.toString()) })
+ }
+
+ override suspend fun read(service: String, characteristic: String): ByteArray = p.read(char(service, characteristic))
+
+ override suspend fun write(service: String, characteristic: String, value: ByteArray) {
+ val known = p.services.value.orEmpty().firstOrNull { it.serviceUuid.toString().equals(service, true) }
+ ?.characteristics?.firstOrNull { it.characteristicUuid.toString().equals(characteristic, true) }
+ // prefer an acknowledged write where the characteristic allows one
+ val type = if (known != null && !known.properties.write) WriteType.WithoutResponse else WriteType.WithResponse
+ p.write(char(service, characteristic), value, type)
+ }
+
+ override suspend fun subscribe(service: String, characteristic: String): Flow<ByteArray> = p.observe(char(service, characteristic))
+
+ override val disconnected: Flow<Unit> = p.state.filterIsInstance<State.Disconnected>().map { }
+
+ override suspend fun disconnect() { runCatching { p.disconnect() }; runCatching { p.close() } }
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt
index 5090ee0..8aa1b6b 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/model/Domain.kt
@@ -23,6 +23,8 @@ data class Solve(
val at: Long,
/** Smart-cube reconstruction, csTimer style: `R@0 U'@120 ...` */
val moves: String? = null,
+ /** Blindfolded with a smart cube: time until the first turn. */
+ val memoMs: Long? = null,
) {
val result: Long get() = when (penalty) {
Penalty.NONE -> ms
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt
new file mode 100644
index 0000000..68890b4
--- /dev/null
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/puzzle/ScrambleTracker.kt
@@ -0,0 +1,156 @@
+package coffee.liz.rookery.puzzle
+
+/** 3x3 helpers in the frame a smart cube reports in: centres fixed, face turns only. */
+object Cube3x3 {
+ private const val FACES = "URFDLB"
+ const val SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
+
+ fun facelets(c: NxnCube): String = buildString(54) { for (s in c.stickers) append(FACES[s]) }
+
+ /**
+ * Rewrite a scramble as plain face turns. A wide move is the opposite face plus a whole-cube
+ * rotation (Rw = L then x), and a smart cube can't see rotations, so later moves are renamed
+ * through them. The result reaches the same cube, just held differently.
+ */
+ fun faceTurns(scramble: String): List<String> {
+ val orient = NxnCube(3) // only its centres matter: which physical face sits where
+ val out = mutableListOf<String>()
+ val re = Regex("""^([URFDLB])(w?)([2']?)$|^([xyz])([2']?)$""")
+ for (t in scramble.split(Regex("\\s+")).filter { it.isNotEmpty() }) {
+ val m = re.matchEntire(t) ?: throw IllegalArgumentException("can't follow move '$t' on a smart cube")
+ if (m.groupValues[4].isNotEmpty()) { orient.apply(t); continue }
+ val face = FACES.indexOf(m.groupValues[1]); val suffix = m.groupValues[3]
+ if (m.groupValues[2].isEmpty()) out += "${FACES[orient.stickers[face * 9 + 4]]}$suffix"
+ else {
+ val opposite = (face + 3) % 6
+ out += "${FACES[orient.stickers[opposite * 9 + 4]]}$suffix"
+ val (rot, flip) = when (FACES[face]) { 'R' -> "x" to false; 'L' -> "x" to true; 'U' -> "y" to false; 'D' -> "y" to true; 'F' -> "z" to false; else -> "z" to true }
+ val sfx = if (!flip) suffix else when (suffix) { "" -> "'"; "'" -> ""; else -> "2" }
+ orient.apply(rot + sfx)
+ }
+ }
+ return out
+ }
+}
+
+/**
+ * Follows a smart cube towards a scramble, the way csTimer does: shows what is left to turn, takes
+ * each move as it comes, and when the cube strays works out the shortest way from where it is to the
+ * scrambled state. The plan is kept until the next stray move, so it doesn't change under your hands.
+ *
+ * [solver] returns moves that solve a facelet string (null if it can't).
+ */
+class ScrambleTracker(private val solver: (facelets: String) -> String?) {
+ private var target: List<String> = emptyList()
+ private var cube = NxnCube(3)
+ private var targetFacelets = Cube3x3.SOLVED
+
+ /** Moves still to do. Empty with [known] true means the cube is scrambled. */
+ var plan: List<String> = emptyList()
+ private set(value) { field = value }
+
+ /**
+ * The whole sequence being worked through (the scramble, or the current fix), which stays put
+ * while [done] fills in, so a display can mark progress without the text moving.
+ */
+ var sequence: List<String> = emptyList()
+ private set
+ /** Indices into [sequence] already turned. */
+ var done: Set<Int> = emptySet()
+ private set
+ /** Index in [sequence] of the move in hand, or -1. */
+ val current: Int get() = remaining.firstOrNull() ?: -1
+ private var remaining = mutableListOf<Int>()
+
+ private fun begin(moves: List<String>) { plan = moves; sequence = moves; done = emptySet(); remaining = moves.indices.toMutableList() }
+ /** False while the plan is the scramble as written; true once it is a computed correction. */
+ var corrected = false
+ private set
+ /** False if we couldn't work out a path (cube state unknown or unsolvable). */
+ var known = false
+ private set
+ val scrambled: Boolean get() = known && plan.isEmpty() && target.isNotEmpty()
+ val total: Int get() = target.size
+
+ /**
+ * Begin tracking a new [scramble]. If we have been following the cube we already know where it
+ * is; otherwise [facelets] (the cube's last report, possibly null) says.
+ */
+ fun reset(scramble: String, facelets: String?) {
+ target = runCatching { Cube3x3.faceTurns(scramble) }.getOrDefault(emptyList())
+ targetFacelets = Cube3x3.facelets(NxnCube(3).apply(target.joinToString(" ")))
+ if (known) start() else sync(facelets)
+ }
+
+ /** Learn the cube's state from a facelets report. Only needed while it is not [known]. */
+ fun sync(facelets: String?) {
+ if (facelets == null) { known = false; begin(emptyList()); return }
+ val solution = solver(facelets)
+ if (solution == null) { known = false; begin(emptyList()); return }
+ cube = NxnCube(3).apply(Notation.invert(solution))
+ known = true
+ start()
+ }
+
+ private fun start() {
+ if (target.isEmpty()) { begin(emptyList()); return }
+ if (cube.isSolved) { begin(target); corrected = false } else replan()
+ }
+
+ fun move(m: String) {
+ if (!known) return
+ cube.apply(m)
+ val face = m[0]; val amount = amount(m)
+ // the next move, or the one after it if the two are opposite faces (they commute)
+ val at = when {
+ plan.isNotEmpty() && plan[0][0] == face -> 0
+ plan.size > 1 && plan[1][0] == face && opposite(plan[0][0]) == face -> 1
+ else -> -1
+ }
+ if (at < 0) { replan(); return }
+ val left = ((amount(plan[at]) - amount) % 4 + 4) % 4
+ plan = plan.toMutableList().also { if (left == 0) it.removeAt(at) else it[at] = face + suffix(left) }
+ if (left == 0) done = done + remaining.removeAt(at)
+ }
+
+ /** The cube as we believe it is, 54 facelets U R F D L B; null while unknown. */
+ val facelets: String? get() = if (known) Cube3x3.facelets(cube) else null
+
+ /**
+ * The cube itself says where it is, and that outranks our move-by-move bookkeeping (a turn can be
+ * lost over Bluetooth). Returns true if we were wrong. The plan is left alone: use [reset] or
+ * [move] afterwards if a scramble is in progress.
+ */
+ fun correct(reported: String): Boolean {
+ if (known && Cube3x3.facelets(cube) == reported) return false
+ if (reported == Cube3x3.SOLVED) { cube = NxnCube(3); known = true; return true }
+ val solution = solver(reported) ?: return false
+ cube = NxnCube(3).apply(Notation.invert(solution)); known = true
+ return true
+ }
+
+ /** Work out the plan again from wherever the cube is now. */
+ fun replanFromHere() { if (known) start() }
+
+ /** A turn made while solving: keep the cube up to date, leave the plan alone. */
+ fun turn(m: String) { if (known) cube.apply(m) }
+
+ val solved: Boolean get() = known && cube.isSolved
+
+ private fun replan() {
+ if (Cube3x3.facelets(cube) == targetFacelets) { plan = emptyList(); done = sequence.indices.toSet(); remaining.clear(); known = true; return }
+ // the cube that, once solved, has taken ours to the target: undo the target, then redo what we have
+ val relative = NxnCube(3).apply(Notation.invert(target.joinToString(" ")))
+ val here = solver(Cube3x3.facelets(cube))
+ if (here == null) { known = false; begin(emptyList()); return }
+ relative.apply(Notation.invert(here))
+ val path = solver(Cube3x3.facelets(relative))
+ if (path == null) { known = false; begin(emptyList()); return }
+ begin(path.split(Regex("\\s+")).filter { it.isNotEmpty() })
+ corrected = true; known = true
+ }
+
+ private fun amount(m: String) = when { m.endsWith("2") -> 2; m.endsWith("'") -> 3; else -> 1 }
+ private fun suffix(n: Int) = when (n) { 2 -> "2"; 3 -> "'"; else -> "" }
+ private fun opposite(f: Char) = "URFDLB"[("URFDLB".indexOf(f) + 3) % 6]
+}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt
index b056e91..c112d7b 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/scramble/Scrambler.kt
@@ -9,6 +9,9 @@ interface Scrambler {
/** An SVG drawing of the scrambled puzzle, or null if unavailable. */
fun previewSvg(event: Event, scramble: String): String?
+ /** Moves that solve a 3x3 given as 54 facelets (U R F D L B), or null if unavailable or unsolvable. */
+ fun solve3x3(facelets: String): String? = null
+
/** Start building solver tables / a scramble ahead of time. */
fun warm(event: Event) {}
}
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
index a7a0994..21cee38 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/store/Repo.kt
@@ -26,6 +26,8 @@ object Coll {
const val PREFS = "prefs"
/** Append-only trainer answers; one entity per answer so concurrent devices never conflict. */
const val REVIEW = "review"
+ /** Per-case algorithm choices and learning status. */
+ const val ALG = "alg"
}
private fun Map<String, JsonElement>.str(f: String): String? = (this[f] as? JsonPrimitive)?.takeIf { this[f] !is JsonNull }?.contentOrNull
@@ -56,6 +58,7 @@ class Repo(val store: Store) {
comment = f.str("note").orEmpty(),
at = f.long("at") ?: 0,
moves = f.str("moves"),
+ memoMs = f.long("memo"),
)
}.sortedWith(compareBy({ it.at }, { it.id }))
}
@@ -87,6 +90,7 @@ class Repo(val store: Store) {
if (s.comment.isNotEmpty()) set(Coll.SOLVE, s.id, "note", s.comment)
set(Coll.SOLVE, s.id, "at", s.at)
if (s.moves != null) set(Coll.SOLVE, s.id, "moves", s.moves)
+ if (s.memoMs != null) set(Coll.SOLVE, s.id, "memo", s.memoMs)
}
fun setPenalty(id: String, p: Penalty) = store.transact { set(Coll.SOLVE, id, "pen", p.code) }
@@ -160,6 +164,17 @@ class Repo(val store: Store) {
}.toMap()
}
+ // ---- algorithms ----
+
+ /** For each case id (`pll:T`): how well you know it, and which algorithm you've chosen (null = the usual one). */
+ fun algPrefs(): Map<String, Pair<coffee.liz.rookery.algs.AlgStatus, String?>> = store.read { r ->
+ r.entities(Coll.ALG).mapValues { (_, f) ->
+ (coffee.liz.rookery.algs.AlgStatus.entries.firstOrNull { it.name == f.str("status") } ?: coffee.liz.rookery.algs.AlgStatus.NEW) to f.str("alg")?.takeIf { it.isNotBlank() }
+ }
+ }
+ fun setAlgStatus(id: String, status: coffee.liz.rookery.algs.AlgStatus) = store.transact { set(Coll.ALG, id, "status", status.name) }
+ fun setAlgChoice(id: String, alg: String?) = store.transact { set(Coll.ALG, id, "alg", alg) }
+
// ---- BLD setup ----
fun bldSetup(): coffee.liz.rookery.bld.BldSetup {
diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt
index f08a550..aab40db 100644
--- a/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt
+++ b/core/src/commonMain/kotlin/coffee/liz/rookery/timer/TimerMachine.kt
@@ -63,6 +63,26 @@ class TimerMachine(var inspection: Boolean = true, var holdMs: Long = 300) {
fun cancel() { state = State.Idle }
+ /** Start inspection without a key press (a smart cube has just been scrambled). */
+ fun beginInspection(now: Long) { if (state is State.Idle || state is State.Stopped) state = State.Inspecting(now) }
+
+ /** Start the clock without a key press (a smart cube made its first move, or blind memo began). */
+ fun beginRunning(now: Long) {
+ state = when (val s = state) {
+ is State.Inspecting -> State.Running(now, inspectionPenalty(s.since, now))
+ State.Idle, is State.Stopped -> State.Running(now, Penalty.NONE)
+ else -> return
+ }
+ }
+
+ /** Stop the clock without a key press (the smart cube is solved). */
+ fun finish(now: Long) {
+ val s = state as? State.Running ?: return
+ val ms = now - s.since
+ state = State.Stopped(ms, s.penalty, awaitingRelease = false)
+ onFinish(ms, s.penalty)
+ }
+
private fun inspectionPenalty(since: Long?, now: Long): Penalty {
if (since == null) return Penalty.NONE
val t = now - since
diff --git a/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt b/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt
new file mode 100644
index 0000000..eadef06
--- /dev/null
+++ b/core/src/commonTest/kotlin/coffee/liz/rookery/AlgSetsTest.kt
@@ -0,0 +1,37 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.algs.AlgCase
+import coffee.liz.rookery.algs.AlgSets
+import coffee.liz.rookery.puzzle.NxnCube
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class AlgSetsTest {
+ private fun f2lSolved(c: NxnCube) = (1..5).all { f -> f == 3 || (3 until 9).all { c.stickers[f * 9 + it] == f } } && (0 until 9).all { c.stickers[3 * 9 + it] == 3 }
+ private fun topOriented(c: NxnCube) = (0 until 9).all { c.stickers[it] == 0 }
+
+ @Test fun setsAreComplete() {
+ assertEquals(21, AlgSets.cases("pll").size)
+ assertEquals(57, AlgSets.cases("oll").size)
+ }
+
+ @Test fun everyAlgorithmSolvesItsOwnCase() {
+ for (set in AlgSets.names) for (case in AlgSets.cases(set)) for (alg in case.algs) {
+ val state = case.state(alg)
+ assertTrue(f2lSolved(state), "${case.id}: '$alg' disturbs the first two layers")
+ if (set == "pll") assertTrue(topOriented(state), "${case.id}: '$alg' is not a pure permutation")
+ if (set == "oll") assertTrue(!topOriented(state), "${case.id}: '$alg' does nothing to orientation")
+ // doing the algorithm on its own setup returns a solved cube (possibly held differently)
+ val done = NxnCube(3).apply(case.setup(alg)).apply(AlgCase.clean(alg))
+ assertTrue((0 until 6).all { f -> (0 until 9).all { done.stickers[f * 9 + it] == done.stickers[f * 9 + 4] } }, "${case.id}: setup then '$alg' is not solved")
+ }
+ }
+
+ @Test fun planPutsFrontAtTheBottom() {
+ val p = AlgSets.plan(NxnCube(3))
+ assertEquals(listOf(-1, 5, 5, 5, -1), p[0].toList())
+ assertEquals(listOf(4, 0, 0, 0, 1), p[2].toList())
+ assertEquals(listOf(-1, 2, 2, 2, -1), p[4].toList())
+ }
+}
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt
index bbccb57..14d35d5 100644
--- a/core/src/jvmMain/kotlin/coffee/liz/rookery/Platform.jvm.kt
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/Platform.jvm.kt
@@ -1,5 +1,6 @@
package coffee.liz.rookery
+actual fun today(): String = java.time.LocalDate.now().toString()
actual fun epochMillis(): Long = System.currentTimeMillis()
actual fun monoMillis(): Long = System.nanoTime() / 1_000_000
actual fun randomId(): String = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
index b96748c..b96748c 100644
--- a/core/src/jvmMain/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/bt/JvmAes128.kt
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
index e0e7b31..4427177 100644
--- a/core/src/jvmMain/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/scramble/TnoodleScrambler.kt
@@ -44,6 +44,14 @@ class TnoodleScrambler : Scrambler {
return s
}
+ private val search by lazy { cs.min2phase.Search() }
+
+ override fun solve3x3(facelets: String): String? {
+ if (facelets == "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB") return ""
+ val s = synchronized(search) { search.solution(facelets, 21, 100_000, 0, 0) }
+ return if (s.startsWith("Error")) null else s.trim().replace(Regex("\\s+"), " ")
+ }
+
override fun previewSvg(event: Event, scramble: String): String? = runCatching {
registry(event).scrambler.drawScramble(scramble, null).toString()
}.getOrNull()
diff --git a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt
index 64064b5..64064b5 100644
--- a/core/src/jvmMain/kotlin/coffee/liz/rookery/store/FolderStorage.kt
+++ b/core/src/jvmShared/kotlin/coffee/liz/rookery/store/FolderStorage.kt
diff --git a/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt b/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt
new file mode 100644
index 0000000..896aaa4
--- /dev/null
+++ b/core/src/jvmTest/kotlin/coffee/liz/rookery/ScrambleTrackerTest.kt
@@ -0,0 +1,78 @@
+package coffee.liz.rookery
+
+import coffee.liz.rookery.puzzle.Cube3x3
+import coffee.liz.rookery.puzzle.NxnCube
+import coffee.liz.rookery.puzzle.ScrambleTracker
+import coffee.liz.rookery.scramble.TnoodleScrambler
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ScrambleTrackerTest {
+ private val solver = TnoodleScrambler()
+ private fun tracker() = ScrambleTracker(solver::solve3x3)
+ private val scramble = "R U2 F' L D B2 R' U L2 F"
+
+ @Test fun followingTheScrambleWalksThePlanDown() {
+ val t = tracker()
+ t.reset(scramble, Cube3x3.SOLVED)
+ assertEquals(scramble.split(" "), t.plan)
+ t.move("R"); assertEquals("U2", t.plan[0])
+ assertEquals(scramble.split(" "), t.sequence); assertEquals(setOf(0), t.done); assertEquals(1, t.current)
+ t.move("U"); assertEquals("U", t.plan[0]) // half of a double turn
+ assertEquals(1, t.current) // still on the same move of the sequence
+ t.move("U"); assertEquals("F'", t.plan[0])
+ t.move("F"); assertEquals("F2", t.plan[0]) // wrong way round: two more fixes it, no solver needed
+ assertFalse(t.corrected)
+ t.move("F2")
+ for (m in listOf("L", "D", "B2", "R'", "U", "L2", "F")) t.move(m)
+ assertTrue(t.scrambled)
+ assertEquals(scramble.split(" "), t.sequence); assertEquals(t.sequence.indices.toSet(), t.done)
+ }
+
+ @Test fun oppositeFacesCanBeDoneInEitherOrder() {
+ val t = tracker()
+ t.reset("R L U", Cube3x3.SOLVED)
+ t.move("L"); assertEquals(listOf("R", "U"), t.plan)
+ assertFalse(t.corrected)
+ }
+
+ @Test fun strayMoveGetsAShortFixThatStaysPutAndReachesTheScramble() {
+ val t = tracker()
+ t.reset(scramble, Cube3x3.SOLVED)
+ t.move("R"); t.move("U2"); t.move("D") // D is not next
+ assertTrue(t.corrected)
+ val fix = t.plan
+ assertTrue(fix.size <= 21, "fix is ${fix.size} moves")
+ // following the fix: the rest of the plan is exactly its tail each time (cached, not re-solved)
+ val cube = NxnCube(3).apply("R U2 D")
+ for ((i, m) in fix.withIndex()) { t.move(m); cube.apply(m); assertEquals(fix.drop(i + 1), t.plan) }
+ assertTrue(t.scrambled)
+ assertEquals(Cube3x3.facelets(NxnCube(3).apply(scramble)), Cube3x3.facelets(cube))
+ }
+
+ @Test fun startsFromWhateverStateTheCubeIsIn() {
+ val t = tracker()
+ t.reset(scramble, Cube3x3.facelets(NxnCube(3).apply("F R U")))
+ assertTrue(t.known && t.corrected)
+ val cube = NxnCube(3).apply("F R U")
+ for (m in t.plan.toList()) { t.move(m); cube.apply(m) }
+ assertTrue(t.scrambled)
+ assertEquals(Cube3x3.facelets(NxnCube(3).apply(scramble)), Cube3x3.facelets(cube))
+ // then solving it is noticed
+ for (m in solver.solve3x3(Cube3x3.facelets(cube))!!.split(" ")) t.move(m)
+ assertTrue(t.solved)
+ }
+
+ @Test fun wideMovesBecomeFaceTurnsReachingTheSameCubeUpToHowItIsHeld() {
+ val bld = "R U F2 Rw Uw'"
+ val faces = Cube3x3.faceTurns(bld)
+ assertEquals(listOf("R", "U", "F2", "L"), faces.take(4))
+ // same cube: rotating the wide-move result back onto its centres gives the face-turn result
+ val wide = NxnCube(3).apply(bld)
+ val plain = Cube3x3.facelets(NxnCube(3).apply(faces.joinToString(" ")))
+ val rotations = listOf("", "x", "x2", "x'", "z", "z'").flatMap { a -> listOf("", "y", "y2", "y'").map { "$a $it".trim() } }
+ assertTrue(rotations.any { Cube3x3.facelets(wide.copy().apply(it)) == plain })
+ }
+}
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index 44a8554..2705014 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -21,7 +21,15 @@ compose.desktop {
nativeDistributions {
// From `./gradlew :desktop:suggestRuntimeModules`; jdk.security.auth is needed by dbus-java (Bluetooth).
modules("java.instrument", "java.net.http", "jdk.security.auth", "jdk.unsupported")
- targetFormats(TargetFormat.AppImage, TargetFormat.Deb)
+ targetFormats(TargetFormat.AppImage, TargetFormat.Deb, TargetFormat.Dmg)
+ macOS {
+ infoPlist {
+ extraKeysRawXml = """
+ <key>NSBluetoothAlwaysUsageDescription</key><string>Connects to your smart cube.</string>
+ <key>NSMicrophoneUsageDescription</key><string>Listens to a stackmat timer on an audio input.</string>
+ """
+ }
+ }
packageName = "rookery"
packageVersion = "0.1.0"
}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
index 171a6d9..7c9b65f 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/CubeCli.kt
@@ -1,7 +1,6 @@
package coffee.liz.rookery.desktop
import coffee.liz.rookery.bt.BleAdvertisement
-import coffee.liz.rookery.bt.BlueZTransport
import coffee.liz.rookery.bt.GanCubeConnector
import coffee.liz.rookery.bt.GanSmartCube
import coffee.liz.rookery.bt.JvmAes128
@@ -9,6 +8,7 @@ import coffee.liz.rookery.bt.SmartCubeEvent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
@@ -28,7 +28,7 @@ fun cubeCli(args: List<String>) {
exitProcess(0)
}
-private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport ->
+private fun scan(seconds: Long, all: Boolean) = bleTransport().let { transport ->
println("Scanning for ${if (all) "BLE devices" else "GAN cubes"} for $seconds s...")
val seen = LinkedHashMap<String, BleAdvertisement>()
runBlocking {
@@ -49,10 +49,17 @@ private fun scan(seconds: Long, all: Boolean) = BlueZTransport().use { transport
private const val SOLVED_FACELETS = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"
-private fun monitor(mac: String) = BlueZTransport().use { transport ->
+private fun monitor(mac: String) = bleTransport().let { transport ->
runBlocking {
println("Connecting to $mac...")
- val cube = GanCubeConnector(transport, JvmAes128).connect(this, mac)
+ // Where the platform hides MACs (macOS), find the cube by the MAC inside its advertisement.
+ val address = if (transport is coffee.liz.rookery.bt.KableTransport) {
+ println("Listening for it (turn the cube to wake it)...")
+ withTimeoutOrNull(20_000) {
+ transport.scan().first { coffee.liz.rookery.bt.GanDefinitions.macFromManufacturerData(it.manufacturerData)?.equals(mac, true) == true || it.address.equals(mac, true) }
+ }?.address ?: run { println("Didn't hear a cube with that MAC."); return@runBlocking }
+ } else mac
+ val cube = GanCubeConnector(transport, JvmAes128).connect(this, address, mac)
println("Connected: ${cube.name ?: "?"} (${(cube as? GanSmartCube)?.generation ?: "?"})")
val done = CompletableDeferred<Unit>()
Runtime.getRuntime().addShutdownHook(Thread { runBlocking { withTimeoutOrNull(2000) { cube.disconnect() } } })
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
index 085ad9a..12a856a 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Main.kt
@@ -75,6 +75,10 @@ class LocalConfig {
(System.getenv("XDG_DATA_HOME")?.let { Paths.get(it) } ?: Paths.get(System.getProperty("user.home"), ".local", "share")).resolve("rookery")
}
+/** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. `ROOKERY_BLE=kable` forces Kable. */
+fun bleTransport(): coffee.liz.rookery.bt.BleTransport =
+ if (System.getProperty("os.name").lowercase().contains("linux") && System.getenv("ROOKERY_BLE") != "kable") BlueZTransport() else coffee.liz.rookery.bt.KableTransport()
+
class DesktopHost(private val config: LocalConfig) : Host {
override fun playChime(notes: List<Chime.Note>) = ChimePlayer.play(notes)
@@ -110,6 +114,7 @@ class DesktopHost(private val config: LocalConfig) : Host {
override fun setSyncFolder(path: String) { config.syncFolder = path }
override val deviceId: String get() = config.deviceId
override val braveApiKey: String? get() = config.braveApiKey
+ override fun setBraveApiKey(key: String?) = config.set("braveApiKey", key)
override val touchInput: Boolean get() = false
private val http: java.net.http.HttpClient by lazy {
@@ -127,8 +132,7 @@ class DesktopHost(private val config: LocalConfig) : Host {
return res.body().use { it.readNBytes(MAX_DOWNLOAD + 1) }.also { check(it.size <= MAX_DOWNLOAD) { "that file is too large" } }
}
- override suspend fun searchImages(query: String): List<ImageHit> = withContext(Dispatchers.IO) {
- val key = config.braveApiKey ?: error("No Brave Search key on this machine.")
+ override suspend fun searchImages(query: String, key: String): List<ImageHit> = withContext(Dispatchers.IO) {
val body = try {
get(BraveImages.url(query), mapOf("Accept" to "application/json", BraveImages.KEY_HEADER to key)).decodeToString()
} catch (e: java.io.IOException) { error("couldn't reach Brave (${e.message ?: "offline?"}).") }
@@ -137,6 +141,35 @@ class DesktopHost(private val config: LocalConfig) : Host {
override suspend fun fetchBytes(url: String): ByteArray = withContext(Dispatchers.IO) { get(url) }
+ /** Picture bytes from the clipboard: an image that was pasted, else a picture file that was copied. */
+ override suspend fun clipboardImage(): ByteArray? = withContext(Dispatchers.Main) {
+ val contents = runCatching { java.awt.Toolkit.getDefaultToolkit().systemClipboard.getContents(null) }.getOrNull() ?: return@withContext null
+ val flavors = runCatching { contents.transferDataFlavors }.getOrNull() ?: return@withContext null
+ when {
+ flavors.contains(java.awt.datatransfer.DataFlavor.imageFlavor) -> withContext(Dispatchers.IO) {
+ // a pasted image arrives as an AWT Image; re-encode it as PNG so shrinkImage can read it
+ runCatching {
+ val img = contents.getTransferData(java.awt.datatransfer.DataFlavor.imageFlavor) as java.awt.Image
+ val w = img.getWidth(null); val h = img.getHeight(null)
+ if (w < 1 || h < 1) return@runCatching null
+ val bmp = java.awt.image.BufferedImage(w, h, java.awt.image.BufferedImage.TYPE_INT_ARGB)
+ val g = bmp.createGraphics()
+ g.drawImage(img, 0, 0, null); g.dispose()
+ java.io.ByteArrayOutputStream().use { out -> javax.imageio.ImageIO.write(bmp, "png", out); out.toByteArray() }
+ }.getOrNull()
+ }
+ flavors.contains(java.awt.datatransfer.DataFlavor.javaFileListFlavor) -> withContext(Dispatchers.IO) {
+ runCatching {
+ @Suppress("UNCHECKED_CAST")
+ val files = contents.getTransferData(java.awt.datatransfer.DataFlavor.javaFileListFlavor) as List<java.io.File>
+ val exts = listOf("jpg", "jpeg", "png", "webp", "gif")
+ files.firstOrNull { it.extension.lowercase() in exts }?.readBytes()
+ }.getOrNull()
+ }
+ else -> null
+ }
+ }
+
/** Fit inside 640px and re-encode as WebP, which is what gets synced. */
override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = runCatching {
val img = org.jetbrains.skia.Image.makeFromEncoded(bytes)
@@ -151,9 +184,9 @@ class DesktopHost(private val config: LocalConfig) : Host {
data.bytes to "webp"
}.getOrNull()
- /** Bluetooth goes through BlueZ, so Linux only for now. */
- override val cubes: CubeBackend? = if (!System.getProperty("os.name").lowercase().contains("linux")) null else object : CubeBackend {
- private val transport by lazy { BlueZTransport() }
+ /** Linux talks to BlueZ directly (tested against a real cube); macOS and Windows go through Kable. */
+ override val cubes: CubeBackend? = object : CubeBackend {
+ private val transport by lazy { bleTransport() }
override fun scan() = transport.scan().filter { GanCubeConnector.isGanCube(it) }
override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = GanCubeConnector(transport, JvmAes128).connect(scope, address, mac)
}
diff --git a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
index 6a00c24..1846c46 100644
--- a/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
+++ b/desktop/src/main/kotlin/coffee/liz/rookery/desktop/Shots.kt
@@ -29,6 +29,8 @@ import kotlin.random.Random
* `rookery --shots <dir>`: draws each screen offscreen with made-up data and writes PNGs. A development
* aid for looking at the UI without a window (or anyone's real data) involved.
*/
+val fakeCubeEvents = kotlinx.coroutines.flow.MutableSharedFlow<coffee.liz.rookery.bt.SmartCubeEvent>(extraBufferCapacity = 256)
+
fun shots(args: List<String>) {
val out = File(args.firstOrNull() ?: "shots").apply { mkdirs() }
val repo = Repo(Store("shots", MemoryLogStorage(), MemoryBlobStore(), System::currentTimeMillis))
@@ -52,20 +54,18 @@ fun shots(args: List<String>) {
override fun setSyncFolder(path: String) {}
override val deviceId = "sample-device"
override val braveApiKey: String? = "x"
+ override fun setBraveApiKey(key: String?) {}
override val touchInput = false
- override suspend fun searchImages(query: String) = emptyList<ImageHit>()
+ override suspend fun searchImages(query: String, key: String) = emptyList<ImageHit>()
override suspend fun fetchBytes(url: String) = ByteArray(0)
+ override suspend fun clipboardImage(): ByteArray? = null
override fun shrinkImage(bytes: ByteArray): Pair<ByteArray, String>? = null
override fun setFullscreen(on: Boolean) {}
override val cubes = object : coffee.liz.rookery.bt.CubeBackend {
override fun scan() = kotlinx.coroutines.flow.flowOf(coffee.liz.rookery.bt.BleAdvertisement("AA:BB:CC:00:11:22", "GANicE3_1122", -58))
override suspend fun connect(scope: CoroutineScope, address: String, mac: String) = object : coffee.liz.rookery.bt.SmartCube {
override val name = "GANicE3_1122"; override val address = address
- override val events = kotlinx.coroutines.flow.flow {
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87))
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets("UUUUUUFFFRRRRRRRRRFFDFFDFFDDDDDDDBBBLLLLLLLLLUBBUBBUBB"))
- emit(coffee.liz.rookery.bt.SmartCubeEvent.Move("F", null, 0)); kotlinx.coroutines.awaitCancellation()
- }
+ override val events = fakeCubeEvents
override suspend fun requestFacelets() {}; override suspend fun requestBattery() {}; override suspend fun requestHardware() {}
override suspend fun resetToSolved() {}; override suspend fun disconnect() {}
}
@@ -96,9 +96,53 @@ fun shots(args: List<String>) {
shoot("party", settle = 1800) { model.testParty() }
model.party = null
for (mode in BldMode.entries) shoot("bld-${mode.name.lowercase()}", settle = 4000) { model.tab = Tab.BLD; model.bldMode = mode }
+ // phone-sized renders
+ shoot("phone-timer", w = 411, h = 890) { model.tab = Tab.TIMER }
+ shoot("phone-sessions", w = 411, h = 890, settle = 800) { model.timerPane = "solves" }
+ model.timerPane = "timer"
+ shoot("phone-targets", w = 411, h = 890, settle = 3000) { model.tab = Tab.BLD; model.bldMode = BldMode.TARGETS }
+ shoot("phone-display-landscape", w = 890, h = 411) { model.changeDisplayMode(true) }
+ model.changeDisplayMode(false)
+ shoot("algs") { model.tab = Tab.ALGS }
+ shoot("phone-algs", w = 411, h = 890) { model.tab = Tab.ALGS }
shoot("settings") { model.tab = Tab.SETTINGS; model.cube.scan(1) }
- shoot("settings-cube") { model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true) }
+ shoot("settings-cube") {
+ model.cube.connect("AA:BB:CC:00:11:22", "GANicE3_1122"); model.stackmat.change(true)
+ Thread.sleep(300)
+ fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Battery(87))
+ fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Facelets(coffee.liz.rookery.puzzle.Cube3x3.SOLVED))
+ }
model.stackmat.change(false)
+
+ // Walk a whole smart-cube solve through the model with the fake cube.
+ fun turn(m: String) { fakeCubeEvents.tryEmit(coffee.liz.rookery.bt.SmartCubeEvent.Move(m, null, 0)); Thread.sleep(60) }
+ fun check(what: String, ok: Boolean) = println((if (ok) "ok " else "FAIL ") + what)
+ model.changeAutoStart(0)
+ for ((label, ev) in listOf("3x3" to Event.E333, "3bld" to Event.E333BF)) {
+ model.createSession(ev); Thread.sleep(2500)
+ val plan0 = model.smartPlan
+ check("$label: plan follows the scramble (${plan0.size} moves)", model.smartKnown && plan0.isNotEmpty() && !model.smartCorrected)
+ turn(plan0[0]); check("$label: first move consumed", model.smartPlan.size == plan0.size - 1 || model.smartPlan[0][0] == plan0[0][0])
+ val next = model.smartPlan[0][0]
+ val wrong = "URFDLB".first { it != next && "URFDLB"[("URFDLB".indexOf(it) + 3) % 6] != next }.toString()
+ turn(wrong); check("$label: stray move gives a fix (${model.smartPlan.size} moves)", model.smartCorrected && model.smartPlan.isNotEmpty())
+ if (label == "3x3") shoot("timer-smart", settle = 600) { model.tab = Tab.TIMER }
+ for (mv in model.smartPlan.toList()) turn(mv)
+ check("$label: scrambled", model.smartScrambled)
+ Thread.sleep(700)
+ val st = model.timer
+ if (ev.blind) {
+ check("$label: memo started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Running && model.memoMs == null)
+ shoot("timer-memo", settle = 900) { }
+ } else check("$label: inspection started by itself", st is coffee.liz.rookery.timer.TimerMachine.State.Inspecting)
+ val before = repo.solves().size
+ val solution = TnoodleScrambler().solve3x3(coffee.liz.rookery.puzzle.Cube3x3.facelets(coffee.liz.rookery.puzzle.NxnCube(3).apply(coffee.liz.rookery.puzzle.Cube3x3.faceTurns(model.scramble).joinToString(" "))))!!.split(" ")
+ for (mv in solution) turn(mv)
+ Thread.sleep(500)
+ val solve = repo.solves().maxByOrNull { it.at }
+ check("$label: solve recorded when the cube was solved", repo.solves().size == before + 1 && solve?.penalty == Penalty.NONE && solve.moves?.split(' ')?.size == solution.size)
+ if (ev.blind) check("$label: memo split stored (${solve?.memoMs} ms)", solve?.memoMs != null)
+ }
shoot("import") { model.tab = Tab.IMPORT }
model.setTheme(ThemeMode.LIGHT)
shoot("timer-paper") { model.tab = Tab.TIMER }
diff --git a/gradle.properties b/gradle.properties
index 85a128d..e09a7c6 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,6 +1,6 @@
# Sized for small machines (this is developed on a 4 GB laptop): one modest Gradle heap,
# Kotlin compiled inside it rather than in a second daemon, and only two workers at a time.
-org.gradle.jvmargs=-Xmx1280m -XX:MaxMetaspaceSize=384m -XX:+UseSerialGC -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx1280m -XX:MaxMetaspaceSize=640m -XX:+UseSerialGC -Dfile.encoding=UTF-8
kotlin.compiler.execution.strategy=in-process
org.gradle.workers.max=2
org.gradle.caching=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index afef406..dffb52f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,14 +4,19 @@ compose = "1.12.2"
coroutines = "1.11.0"
serialization = "1.13.0"
tnoodle = "0.20.0"
+kable = "0.45.0"
+agp = "9.5.1"
+activity = "1.13.0"
dbus-java = "5.2.2"
[libraries]
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
tnoodle-scrambles = { module = "org.worldcubeassociation.tnoodle:lib-scrambles", version.ref = "tnoodle" }
+tnoodle-min2phase = { module = "org.worldcubeassociation.tnoodle:scrambler-min2phase", version.ref = "tnoodle" }
dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbus-java" }
dbus-java-transport-native = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbus-java" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" }
@@ -19,7 +24,13 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose" }
compose-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose" }
+kable = { module = "com.juul.kable:kable-core", version.ref = "kable" }
+
+activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
+
[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index cae62dd..435cf5f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -15,4 +15,4 @@ dependencyResolutionManagement {
}
}
-include(":core", ":ui", ":desktop")
+include(":core", ":ui", ":desktop", ":android")
diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts
index 28e4ef6..76c559c 100644
--- a/ui/build.gradle.kts
+++ b/ui/build.gradle.kts
@@ -2,11 +2,18 @@ plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose)
+ alias(libs.plugins.android.kmp.library)
}
kotlin {
jvmToolchain(21)
jvm()
+ androidLibrary {
+ namespace = "coffee.liz.rookery.ui"
+ compileSdk = 37
+ minSdk = 26
+ androidResources { enable = true } // for Compose resources (the penguin)
+ }
sourceSets {
commonMain.dependencies {
diff --git a/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt b/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt
new file mode 100644
index 0000000..4463466
--- /dev/null
+++ b/ui/src/androidMain/kotlin/coffee/liz/rookery/ui/Svg.android.kt
@@ -0,0 +1,7 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.Density
+
+/** Android has no built-in SVG renderer; scramble drawings fall back to the 3D view where there is one. */
+actual fun svgPainter(svg: String, density: Density): Painter? = null
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)
+ }
}
}
}
diff --git a/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt b/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt
new file mode 100644
index 0000000..520e6a9
--- /dev/null
+++ b/ui/src/jvmMain/kotlin/coffee/liz/rookery/ui/Svg.jvm.kt
@@ -0,0 +1,9 @@
+package coffee.liz.rookery.ui
+
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.unit.Density
+import org.jetbrains.compose.resources.ExperimentalResourceApi
+import org.jetbrains.compose.resources.decodeToSvgPainter
+
+@OptIn(ExperimentalResourceApi::class)
+actual fun svgPainter(svg: String, density: Density): Painter? = runCatching { svg.encodeToByteArray().decodeToSvgPainter(density) }.getOrNull()