diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..66d5d3d --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "webApp-wasm", + "runtimeExecutable": "./gradlew", + "runtimeArgs": [":webApp:wasmJsBrowserDevelopmentRun", "-PopenBrowser=false"], + "port": 8080 + } + ] +} diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index c8e2912..6ff8825 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -50,7 +50,24 @@ the suppression is clearly justified. If it comes back with more than a handful of findings, hand off to the **`detekt-triage`** agent instead of reading the raw output — it returns them grouped by rule with counts. -### 4. Build each touched module +### 4. Compose stability + +``` +./gradlew composeStabilityCheck +``` + +Only if a touched module is one of the six that apply the Compose compiler plugin — `:ui`, +`:domain`, `:shared`, `:app`, `:webApp`, `:desktopApp`. It reads the Compose compiler's own +reports and fails on a class inferred `unstable` or a `restartable` composable that is not +`skippable`. + +Run it as its own invocation, never folded into `./gradlew build ...`: every target of a module +writes over the same reports directory, and this task's single designated compile task is what +makes the result deterministic. The failure names each offender and the allowlist line to add. +Prefer fixing the type over allowlisting — CLAUDE.md, *Compose stability*, says which annotation +belongs where. + +### 5. Build each touched module ``` ./gradlew ::build @@ -60,7 +77,7 @@ One invocation per touched module. This covers all targets (android, jvm, iosArm iosSimulatorArm64, js, wasmJs), which is the point — a change that compiles on JVM can still break `expect`/`actual` or a web target. -### 5. Tests +### 6. Tests ``` ./gradlew :domain:jvmTest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5955f6..eb54599 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,5 +45,40 @@ jobs: - name: Detekt run: ./gradlew detektAll + # Before Build on purpose. `reportsDestination` is a project-level Compose compiler setting + # that every target of a module writes to, so only this invocation -- which compiles the one + # designated target -- leaves a single writer and a deterministic report. + - name: Compose stability + run: ./gradlew composeStabilityCheck + + # The Compose compiler names its reports after the Kotlin module, which here is the Gradle + # project path -- `Recipes:domain-classes.txt`. upload-artifact rejects a colon in a path, so + # stage a renamed copy. Done immediately after the check rather than at the end of the job, so + # the artifact is exactly what the check read: `build` below recompiles the other targets over + # the same directory. + - name: Stage Compose reports + if: always() + run: | + mkdir -p compose-reports + for src in */build/reports/compose; do + [ -d "$src" ] || continue + mkdir -p "compose-reports/${src%%/*}" + cp -R "$src/." "compose-reports/${src%%/*}/" + done + find compose-reports -depth -name '*:*' | while read -r path; do + mv "$path" "$(dirname "$path")/$(basename "$path" | tr ':' '_')" + done + + # continue-on-error: this is diagnostics. A bad upload must not fail the job and skip `Build`, + # which is exactly what happened the first time this workflow ran. + - name: Upload Compose reports + if: always() + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: compose-reports + path: compose-reports + if-no-files-found: warn + - name: Build run: ./gradlew build diff --git a/.gitignore b/.gitignore index 7cedd6e..b209558 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ local.properties # Per-user scheme and window state; Xcode rewrites these on every open. xcuserdata/ -.DS_Store \ No newline at end of file +.DS_Store + +# CI stages a renamed copy of the Compose stability reports here before uploading them. +compose-reports/ diff --git a/CLAUDE.md b/CLAUDE.md index aaedf45..d862d4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,71 @@ Two things are easy to get wrong: `ListContent.kt` bridges the two, and belongs in `:ui` with the rest of the user-facing copy once these get localized. +### Compose stability + +Everything a presenter hands to a composable has to be *stable*, or Compose re-runs the UI on every +recomposition. Two rules keep it that way: + +- **Collections headed for the UI are `ImmutableList`**, from the repository's public signature + onward — `SourceOfTruth.reader`, `toModel()`, the `Flow>` return type, the producer's + `ContentState>`, and the screen state's field. Entities and DTOs stay `List`: + Store's local type is `List` and nothing below `toModel()` ever reaches composition, so + converting there is a copy that buys nothing. `:model` carries + `api(libs.kotlinx.collections.immutable)` for this. +- **`@Immutable` when it is true, `@Stable` when it is not.** `@Immutable` promises no public + property ever changes: right for the `:model` classes and for a screen state whose fields are all + values (`AreasState`, `RecipesState`, `RecipeDetailsState`, `SearchTabState`, `RecipesScreen`). + A state that carries an observable holder — `SearchBarState` and `TextFieldState` in `SearchState`, + `NavStack`/`Navigator` in `RecipeScaffoldState` and `HomeState`, the Metro graph in + `DesktopAppGraph` — gets `@Stable`. Both make the type skippable; only one of them is honest, and + a class marked `@Stable` must back its mutable properties with snapshot state (this is why + `BackShortcutHost.onBack` is a `MutableState`). + +Annotate the *declared parameter type*: `@Stable` on a supertype does not propagate, so +`DesktopAppGraph` carries its own annotation rather than inheriting one from `AppGraph`. + +A sealed `CircuitUiState` interface is unstable until annotated — the compiler cannot see the +implementations — so every one of them carries a marker. + +**None of the above is taken on trust — `./gradlew composeStabilityCheck` enforces it.** The +`compose.stability` convention plugin turns on the Compose compiler's own metrics and reports for +the six modules that apply the compiler plugin (`:ui`, `:domain`, `:shared`, `:app`, `:webApp`, +`:desktopApp`) and registers a per-project check that reads them. It fails on a class the compiler +inferred as `unstable`, and on a `restartable` composable that is not `skippable`. A `runtime` +class passes: its stability depends on a generic argument and is settled at run time. There is no +aggregate task — Gradle's cross-project name matching fans `composeStabilityCheck` out to all six. + +Exceptions live in `config/compose/unstable-classes.txt` and +`config/compose/unskippable-composables.txt`, one `:` per line, each +with a comment saying why. Today only the Metro graph impls and the two Android entry points are +listed, none of which is ever a composable parameter. + +Reports land in `/build/reports/compose/reports/` (`-classes.txt`, `-composables.txt`) with +`-module.json` counts under `.../metrics//main/`. Read them for *why* something failed — +the entry lists each property or parameter and its verdict. The compiler names them after the +Kotlin module, which is the Gradle project path — so they carry a colon, `Recipes:domain-classes.txt`, +and CI stages a renamed copy before uploading them because `upload-artifact` rejects one. + +Two traps in those reports: + +- **Only the metrics path is per-target; the reports path is not.** The Compose plugin appends + `/` to `metricsDestination` but hands `reportsDestination` to every + compilation verbatim, and a KMP module's targets share a Kotlin module name. So all six targets + write the same `Recipes_ui-classes.txt` and the last to run wins. After a full `./gradlew build` + you are reading whichever target finished last. `composeStabilityCheck` is the invocation to + trust: it depends on exactly one compile task (JVM where there is one, `compileDebugKotlin` for + `:app`, `compileKotlinJs` for `:webApp`), so it has a single writer. That is also why it is not + wired into `check`, and why CI runs it as its own step *before* `build`. +- **`reportsDestination` is a compiler *input*, not an output.** A compile task whose reports have + been deleted still counts as up to date and quietly writes nothing. `compose.stability` declares + the directory as an output of that one designated compile task to fix it, which also means the + task re-runs after a full `build` and stays the last writer. + +Detekt's `UnstableCollections` covers the same ground at the source level and is on. It is right +about composables that emit UI and wrong about ones that return a value — those are neither +restartable nor skippable, so a parameter's stability is inert. `RecipesProducer.produceByIngredients` +is the single `@Suppress` for that reason. + **A test fake for an in-flight request must not complete.** `produceRetainedContentState` settles a still loading status when its source completes, so a spinner can never hang — which means `flowOf(Outcome.Loading)` is a *settled empty result*, not a pending one. Store streams never diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecd1a32..1b6e7ae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.compose.multiplatform) alias(libs.plugins.metro) alias(libs.plugins.dependency.sorter) + id("compose.stability") id("dependency.analysis") id("detekt") id("formatting") diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 26efb03..0911b6f 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { compileOnly(libs.plugins.dependency.sorter.toDep()) compileOnly(libs.plugins.detekt.toDep()) compileOnly(libs.plugins.kmp.parcelize.toDep()) + compileOnly(libs.plugins.kotlin.compose.toDep()) compileOnly(libs.plugins.ktfmt.toDep()) detektPlugins( diff --git a/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeReportTarget.kt b/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeReportTarget.kt new file mode 100644 index 0000000..c4dce04 --- /dev/null +++ b/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeReportTarget.kt @@ -0,0 +1,53 @@ +package com.scottolcott.gradle + +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType + +/** + * The one compile task whose Compose reports the stability check reads. + * + * `reportsDestination` is a project-level setting the Compose plugin hands to every compilation + * verbatim -- unlike `metricsDestination`, it gets no per-target subdirectory -- and a KMP module's + * targets share a Kotlin module name (`ui/build/classes/kotlin/jvm/main` and `.../android/main` + * both hold `Recipes_ui.kotlin_module`). So all six targets write the same `Recipes_ui-classes.txt` + * and the last one to run wins. Depending on exactly one compile task leaves a single writer, which + * is what makes `composeStabilityCheck` deterministic. Which target hardly matters: stability + * inference for `commonMain` is identical on all of them, so prefer the JVM for compile speed. + */ +internal fun Project.composeReportCompileTask(): TaskProvider<*> { + val kotlin = + extensions.findByName("kotlin") as? KotlinProjectExtension + ?: error( + "No Kotlin extension in :$name -- compose.stability needs one to find a compilation." + ) + val targets = + when (kotlin) { + is KotlinMultiplatformExtension -> kotlin.targets.toList() + is KotlinSingleTargetExtension<*> -> listOf(kotlin.target) + else -> emptyList() + } + val target = + PLATFORM_PREFERENCE.firstNotNullOfOrNull { platform -> + targets.firstOrNull { it.platformType == platform } + } ?: error("No Kotlin target in :$name can produce Compose reports.") + val compilation = + target.compilations.findByName("main") + // KotlinAndroidTarget names its compilations after build types, so there is no "main". + ?: target.compilations.findByName("debug") + ?: error("Target ${target.name} in :$name has neither a main nor a debug compilation.") + return compilation.compileTaskProvider +} + +/** Metadata is deliberately absent: it compiles no bodies, so the compiler reports nothing. */ +private val PLATFORM_PREFERENCE = + listOf( + KotlinPlatformType.jvm, + KotlinPlatformType.androidJvm, + KotlinPlatformType.js, + KotlinPlatformType.wasm, + KotlinPlatformType.native, + ) diff --git a/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeStabilityCheckTask.kt b/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeStabilityCheckTask.kt new file mode 100644 index 0000000..2203f29 --- /dev/null +++ b/build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeStabilityCheckTask.kt @@ -0,0 +1,218 @@ +package com.scottolcott.gradle + +import java.io.File +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Turns the Compose compiler's own stability reports into a pass or a fail. + * + * Reads the `-classes.txt` and `-composables.txt` files the compiler writes to `reportsDestination` + * and fails on two things: + * * a class the compiler inferred as `unstable` -- every recomposition re-runs any composable that + * takes one; + * * a `restartable` composable that is not `skippable`. Strong skipping is on by default in Kotlin + * 2.4, so what survives is genuine: inline functions, non-`Unit` returns, and + * `@NonSkippableComposable`. + * + * Both are overridable through an allowlist file, one `:` per line. The module + * prefix is what lets each project's copy of this task tell its own entries from another module's. + */ +@CacheableTask +abstract class ComposeStabilityCheckTask : DefaultTask() { + + /** The `*-classes.txt` and `*-composables.txt` files written to `reportsDestination`. */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val reports: ConfigurableFileCollection + + /** Classes allowed to stay unstable. */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val unstableClassAllowlist: RegularFileProperty + + /** Restartable composables allowed to stay unskippable. */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val unskippableComposableAllowlist: RegularFileProperty + + /** The project name, which is the prefix this task's allowlist entries must carry. */ + @get:Input abstract val moduleName: Property + + @get:OutputFile abstract val summary: RegularFileProperty + + @TaskAction + fun check() { + val reportFiles = reports.files.filter { it.isFile } + if (reportFiles.isEmpty()) { + throw GradleException( + "No Compose reports found for :${moduleName.get()}. The compiler writes them only when " + + "composeCompiler.reportsDestination is set -- check the compose.stability convention " + + "plugin is applied to this project." + ) + } + + val unstableClasses = + reportFiles.filter { it.name.endsWith(CLASSES_SUFFIX) }.flatMap(::parseUnstableClasses) + val unskippable = + reportFiles + .filter { it.name.endsWith(COMPOSABLES_SUFFIX) } + .flatMap(::parseUnskippableComposables) + + val allowedClasses = readAllowlist(unstableClassAllowlist.orNull?.asFile) + val allowedComposables = readAllowlist(unskippableComposableAllowlist.orNull?.asFile) + + warnAboutStaleEntries(allowedClasses, unstableClasses, unstableClassAllowlist.orNull?.asFile) + warnAboutStaleEntries( + allowedComposables, + unskippable, + unskippableComposableAllowlist.orNull?.asFile, + ) + + val classFailures = unstableClasses.filterNot { it.name in allowedClasses } + val composableFailures = unskippable.filterNot { it.name in allowedComposables } + + writeSummary(reportFiles, unstableClasses, unskippable, classFailures, composableFailures) + + if (classFailures.isNotEmpty() || composableFailures.isNotEmpty()) { + throw GradleException( + buildFailureMessage(classFailures, composableFailures).also { logger.error(it) } + ) + } + } + + private fun buildFailureMessage( + classFailures: List, + composableFailures: List, + ): String = buildString { + val module = moduleName.get() + appendLine("Compose stability check failed for :$module.") + if (classFailures.isNotEmpty()) { + appendLine() + appendLine(" Unstable classes (${classFailures.size}):") + classFailures.forEach { appendLine(" ${it.name} [${it.report.name}]") } + appendLine( + " Make every property a val of a stable type, or annotate the class @Immutable " + + "(nothing ever changes) or @Stable (mutable properties are backed by snapshot state)." + ) + appendLine(" To accept one, add to ${allowlistPath(unstableClassAllowlist.orNull?.asFile)}:") + classFailures.forEach { appendLine(" $module:${it.name}") } + } + if (composableFailures.isNotEmpty()) { + appendLine() + appendLine(" Restartable but not skippable (${composableFailures.size}):") + composableFailures.forEach { appendLine(" ${it.name} [${it.report.name}]") } + appendLine( + " Every unskippable composable re-runs on each recomposition of its parent. Make its " + + "parameters stable, or accept it below with a comment saying why." + ) + appendLine( + " To accept one, add to ${allowlistPath(unskippableComposableAllowlist.orNull?.asFile)}:" + ) + composableFailures.forEach { appendLine(" $module:${it.name}") } + } + } + + private fun warnAboutStaleEntries(allowed: Set, found: List, file: File?) { + // Only ever a warning: the reports cover one target, so an entry for a class declared in, say, + // iosMain legitimately goes unmatched when the check reads the JVM report. + val foundNames = found.mapTo(mutableSetOf()) { it.name } + val stale = allowed - foundNames + if (stale.isNotEmpty()) { + logger.warn( + "Compose stability allowlist for :${moduleName.get()} has ${stale.size} entries that no " + + "longer appear in the report (${stale.sorted().joinToString()}). Remove them from " + + "${allowlistPath(file)} unless they are declared in a source set this target does not " + + "compile." + ) + } + } + + private fun writeSummary( + reportFiles: List, + unstableClasses: List, + unskippable: List, + classFailures: List, + composableFailures: List, + ) { + val out = summary.get().asFile + out.parentFile.mkdirs() + out.writeText( + buildString { + appendLine("Compose stability -- :${moduleName.get()}") + appendLine("reports read: ${reportFiles.joinToString { it.name }}") + appendLine( + "unstable classes: ${unstableClasses.size} (${classFailures.size} not allowlisted)" + ) + unstableClasses.forEach { appendLine(" ${it.name}") } + appendLine( + "restartable, not skippable: ${unskippable.size} " + + "(${composableFailures.size} not allowlisted)" + ) + unskippable.forEach { appendLine(" ${it.name}") } + } + ) + } + + /** + * Entries for this module, with the `:` prefix stripped. Others are another's problem. + */ + private fun readAllowlist(file: File?): Set { + if (file == null || !file.isFile) return emptySet() + val prefix = "${moduleName.get()}:" + return file + .readLines() + .map { it.substringBefore('#').trim() } + .filter { it.startsWith(prefix) } + .mapTo(mutableSetOf()) { it.removePrefix(prefix).trim() } + } + + private fun parseUnstableClasses(report: File): List = + report.readLines().mapNotNull { line -> + // Entries start at column 0; properties inside a class body are indented. + UNSTABLE_CLASS.find(line)?.let { Finding(it.groupValues[1], report) } + } + + private fun parseUnskippableComposables(report: File): List = + report.readLines().mapNotNull { line -> + val match = COMPOSABLE.find(line) ?: return@mapNotNull null + val flags = match.groupValues[1].trim().split(WHITESPACE) + // `runtime`/`readonly`/inline functions are not restartable and cost nothing to skip. + if (RESTARTABLE !in flags || SKIPPABLE in flags) return@mapNotNull null + Finding(match.groupValues[2], report) + } + + private fun allowlistPath(file: File?): String = file?.invariantSeparatorsPath ?: "the allowlist" + + private data class Finding(val name: String, val report: File) + + private companion object { + const val CLASSES_SUFFIX = "-classes.txt" + const val COMPOSABLES_SUFFIX = "-composables.txt" + const val RESTARTABLE = "restartable" + const val SKIPPABLE = "skippable" + + val UNSTABLE_CLASS = Regex("""^unstable class ([^\s{]+)""") + // e.g. `restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun RecipeGrid(`. + // Names come out fully qualified and may carry angle brackets -- a property getter reads + // `fun com.scottolcott.recipe.domain.()` -- so the optional type-parameter + // group excludes `-` rather than matching any `<...>`, which would swallow those. + val COMPOSABLE = + Regex("""^([a-z ]*(?:scheme\("[^"]*"\)\s*)?)fun\s+(?:<[\w, ]+>\s+)?([^(\s]+)\(""") + val WHITESPACE = Regex("""\s+""") + } +} diff --git a/build-logic/src/main/kotlin/compose.stability.gradle.kts b/build-logic/src/main/kotlin/compose.stability.gradle.kts new file mode 100644 index 0000000..e91a9b6 --- /dev/null +++ b/build-logic/src/main/kotlin/compose.stability.gradle.kts @@ -0,0 +1,50 @@ +import com.scottolcott.gradle.ComposeStabilityCheckTask +import com.scottolcott.gradle.composeReportCompileTask +import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension + +// Turns on the Compose compiler's own metrics and reports and registers the check that reads them. +// Gated on the compiler plugin rather than applying it, the same way kmp.library reacts to +// `org.jetbrains.compose` -- the six Compose modules each declare their own alias. +// +// The destinations are always set rather than hidden behind a `-P` flag, so `composeStabilityCheck` +// is a plain `dependsOn` with nothing to remember on CI. The cost is three small text files per +// compilation. +plugins.withId("org.jetbrains.kotlin.plugin.compose") { + // Not `build/compose`: the Compose Multiplatform plugin already unpacks the skiko runtime there. + val composeDirectory = layout.buildDirectory.dir("reports/compose") + val reportsDirectory = composeDirectory.map { it.dir("reports") } + + configure { + reportsDestination = reportsDirectory + metricsDestination = composeDirectory.map { it.dir("metrics") } + } + + // The Compose plugin hands `reportsDestination` to the compiler as an *input* option, so a + // compile task whose reports have been deleted still counts as up to date and quietly writes + // nothing -- the check would then fail with "no reports found" on a perfectly healthy tree. + // Declaring the directory as an output of the one task the check reads models what the task + // actually produces and fixes that. It buys a second thing too: after a full `build`, where + // every target writes over the shared directory, this task is out of date again and re-runs, so + // it stays the last writer and the check stays deterministic. + afterEvaluate { composeReportCompileTask().configure { outputs.dir(reportsDirectory) } } + + tasks.register("composeStabilityCheck") { + group = "verification" + description = "Fails on Compose stability regressions reported by the Compose compiler." + + // Resolved lazily: Android compilations do not exist when this plugin is applied. + dependsOn(provider { composeReportCompileTask() }) + + reports.from( + reportsDirectory.map { + it.asFileTree.matching { include("*-classes.txt", "*-composables.txt") } + } + ) + unstableClassAllowlist = + rootProject.layout.projectDirectory.file("config/compose/unstable-classes.txt") + unskippableComposableAllowlist = + rootProject.layout.projectDirectory.file("config/compose/unskippable-composables.txt") + moduleName = project.name + summary = composeDirectory.map { it.file("stability-check.txt") } + } +} diff --git a/config/compose/unskippable-composables.txt b/config/compose/unskippable-composables.txt new file mode 100644 index 0000000..7439f86 --- /dev/null +++ b/config/compose/unskippable-composables.txt @@ -0,0 +1,9 @@ +# Composables reported as `restartable` but not `skippable` that we accept anyway. +# +# One `:` per line; `#` starts a comment. The compiler reports fully qualified names, +# so copy them verbatim from the failure message. The module prefix scopes the entry to that +# project's `composeStabilityCheck`. +# +# Strong skipping has been on by default since Kotlin 2.4, so an unskippable composable is no +# longer just "has an unstable parameter" -- what lands here is genuinely unskippable: a non-`Unit` +# return, an inline function, or `@NonSkippableComposable`. Each entry needs a comment saying which. diff --git a/config/compose/unstable-classes.txt b/config/compose/unstable-classes.txt new file mode 100644 index 0000000..7908f78 --- /dev/null +++ b/config/compose/unstable-classes.txt @@ -0,0 +1,26 @@ +# Classes the Compose compiler infers as `unstable` that we accept anyway. +# +# One `:` per line; `#` starts a comment. The compiler reports fully qualified names, +# so copy them verbatim from the failure message. The module prefix scopes the entry to that +# project's `composeStabilityCheck`, so entries for other modules are ignored rather than mistaken +# for stale ones. +# +# Every entry needs a comment saying why the class cannot be made stable. An unstable class makes +# every composable that takes one re-run on each recomposition, so the bar is "the alternative is +# worse", not "this was quicker". The usual fixes come first: make the properties `val`s of stable +# types, swap `List` for `ImmutableList`, or annotate `@Immutable` (nothing ever changes) or +# `@Stable` (mutable properties are backed by snapshot state). See CLAUDE.md, *Compose stability*. + +# The Metro-generated graph implementations. Nothing hands a graph to a composable on these two +# platforms -- `main()` reads `graph.circuit` and `graph.subCircuit` off it and passes those, and +# `RecipeApplication` keeps the Android one to itself. Desktop is the exception and is absent from +# this list for that reason: `RecipeWindow(graph: DesktopAppGraph, ...)` does take one, which is why +# `DesktopAppGraph` carries `@Stable` and why :desktopApp reports nothing here. +webApp:com.scottolcott.recipe.ui.web.WebAppGraph.Impl +app:com.scottolcott.recipe.AndroidAppGraph.Impl + +# Android framework entry points, both full of mutable state the framework owns. Neither is ever a +# composable parameter -- MainActivity calls `setContent { RecipeApp(circuit, subCircuit, ...) }` +# and passes the two Circuit instances, not itself. Annotating either @Stable would be a lie. +app:com.scottolcott.recipe.MainActivity +app:com.scottolcott.recipe.RecipeApplication diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 5862901..db21a0c 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -951,7 +951,7 @@ Compose: StateParam: active: true UnstableCollections: - active: false + active: true ViewModelForwarding: active: true ViewModelInjection: diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 73643f9..a0d33bf 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.metro) alias(libs.plugins.dependency.sorter) + id("compose.stability") id("dependency.analysis") id("detekt") } diff --git a/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt b/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt index b9c5f55..f15b510 100644 --- a/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt +++ b/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt @@ -1,5 +1,6 @@ package com.scottolcott.recipe +import androidx.compose.runtime.Stable import co.touchlab.kermit.Logger import co.touchlab.kermit.NoTagFormatter import co.touchlab.kermit.Severity @@ -26,6 +27,7 @@ import dev.zacsweers.metro.Provides NetworkProviders::class, ], ) +@Stable interface DesktopAppGraph : AppGraph { @Provides diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 5be2b90..a0c9ea7 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -4,6 +4,7 @@ import dev.zacsweers.metro.gradle.ExperimentalMetroGradleApi plugins { id("kmp.library") + id("compose.stability") alias(libs.plugins.metro) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.compose) @@ -46,6 +47,7 @@ kotlin { implementation(projects.core) implementation(projects.repository) + implementation(libs.compose.runtime.annotations) implementation(libs.kermit.core) } } diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenter.kt index 9c11f32..d06a61d 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -16,6 +17,7 @@ import com.slack.circuit.serialization.CircuitSerializable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.redacted.annotations.Redacted +import kotlinx.collections.immutable.ImmutableList @CircuitInject(AreasScreen::class, AppScope::class) @Inject @@ -53,6 +55,7 @@ internal constructor(private val navigator: Navigator, private val areasProducer } } +@Immutable sealed interface AreasState : CircuitUiState { data object Loading : AreasState @@ -61,7 +64,7 @@ sealed interface AreasState : CircuitUiState { AreasState data class Success( - val areas: List, + val areas: ImmutableList, val isRefreshing: Boolean, @Redacted val eventSink: (AreasEvent.Success) -> Unit, ) : AreasState diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenter.kt index 327eec4..a7b30bf 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -19,6 +20,7 @@ import com.slack.circuit.serialization.CircuitSerializable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.redacted.annotations.Redacted +import kotlinx.collections.immutable.ImmutableList @CircuitInject(CategoriesScreen::class, AppScope::class) @Inject @@ -57,6 +59,7 @@ internal constructor( } } +@Immutable sealed interface CategoriesState : CircuitUiState { data object Loading : CategoriesState @@ -64,7 +67,7 @@ sealed interface CategoriesState : CircuitUiState { CategoriesState data class Success( - val categories: List, + val categories: ImmutableList, val isRefreshing: Boolean, @Redacted val eventSink: (CategoriesEvent.Success) -> Unit, ) : CategoriesState diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenter.kt index e021345..fb21155 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -16,6 +17,8 @@ import com.slack.circuit.serialization.CircuitSerializable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.redacted.annotations.Redacted +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable /** @@ -25,8 +28,8 @@ import kotlinx.serialization.Serializable * page order. `ScreenUrlMapper` reads this same list to map both directions of `/home/{tab}`, so a * tab cannot end up in the pager but missing from the URL mapping. */ -internal val HOME_TABS: List = - listOf(CategoriesScreen, IngredientsScreen, AreasScreen) +internal val HOME_TABS: ImmutableList = + persistentListOf(CategoriesScreen, IngredientsScreen, AreasScreen) @CircuitInject(HomeScreen::class, AppScope::class) @Inject @@ -56,10 +59,11 @@ internal constructor(private val screen: HomeScreen, private val navigator: Navi } } +@Stable data class HomeState( val selectedTabScreen: HomeTabScreen, val selectedIndex: Int, - val tabScreens: List, + val tabScreens: ImmutableList, val navigator: Navigator, @Redacted val eventSink: (HomeEvent) -> Unit, ) : CircuitUiState diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenter.kt index df49082..ec4837d 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -16,6 +17,7 @@ import com.slack.circuit.serialization.CircuitSerializable import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.redacted.annotations.Redacted +import kotlinx.collections.immutable.ImmutableList @CircuitInject(IngredientsScreen::class, AppScope::class) @Inject @@ -56,15 +58,15 @@ internal constructor( } } +@Immutable sealed interface IngredientsState : CircuitUiState { - data object Loading : IngredientsState data class Error(val message: String, @Redacted val eventSink: (IngredientsEvent.Error) -> Unit) : IngredientsState data class Success( - val ingredients: List, + val ingredients: ImmutableList, val isRefreshing: Boolean, @Redacted val eventSink: (IngredientsEvent.Success) -> Unit, ) : IngredientsState diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/ListContent.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/ListContent.kt index 83e271e..1f8204b 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/ListContent.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/ListContent.kt @@ -5,6 +5,7 @@ import io.github.solcott.uistate.ContentState import io.github.solcott.uistate.errorOrNull import io.github.solcott.uistate.hasAnswer import io.github.solcott.uistate.isLoading +import kotlinx.collections.immutable.ImmutableList /** * The three states every list tab renders, decoupled from any one screen's `CircuitUiState`. @@ -24,13 +25,13 @@ import io.github.solcott.uistate.isLoading * has never seen, so it gets the spinner, or the error, rather than "nothing found". The * repositories already hold that read back with `asOutcomes(fetching = …)`; this is the backstop. */ -internal inline fun ContentState>.foldToState( +internal inline fun ContentState>.foldToState( onLoading: () -> S, onError: (message: String) -> S, - onContent: (items: List, isRefreshing: Boolean) -> S, + onContent: (items: ImmutableList, isRefreshing: Boolean) -> S, ): S = when { - hasAnswer(List::isEmpty) -> onContent(data, isLoading) + hasAnswer(ImmutableList::isEmpty) -> onContent(data, isLoading) isLoading -> onLoading() else -> onError(errorOrNull.toMessage()) } diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenter.kt index e79fcdd..a0cf42e 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -82,6 +83,7 @@ internal constructor( } } +@Immutable sealed interface RecipeDetailsState : CircuitUiState { data object Loading : RecipeDetailsState diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeScaffoldPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeScaffoldPresenter.kt index a4d3668..6ba9932 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeScaffoldPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipeScaffoldPresenter.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SearchBarValue import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.retain.retain @@ -304,6 +305,7 @@ sealed interface RecipeScaffoldEvent : CircuitUiEvent { constructor(val searchBarValue: SearchBarValue) : RecipeScaffoldEvent } +@Stable data class RecipeScaffoldState( val navStack: NavStack, val navigator: Navigator, diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenter.kt index fe41fbe..4f5a01a 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.retain.retain @@ -19,6 +20,7 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.redacted.annotations.Redacted import io.github.solcott.uistate.ContentState +import kotlinx.collections.immutable.ImmutableList @CircuitInject(RecipesScreen::class, AppScope::class) @Inject @@ -65,7 +67,7 @@ internal constructor( private fun produceRecipesState( screen: RecipesScreen, retryTrigger: Int, - ): ContentState> { + ): ContentState> { return when (screen) { is RecipesScreen.ByCategory -> recipesProducer.produceByCategory(screen.category, retryTrigger) @@ -89,6 +91,7 @@ sealed interface RecipesEvent : CircuitUiEvent { } } +@Immutable sealed interface RecipesState : CircuitUiState { data object Loading : RecipesState @@ -102,13 +105,14 @@ sealed interface RecipesState : CircuitUiState { * travel to reach it -- the name cannot be resolved here. */ val screen: RecipesScreen, - val recipes: List, + val recipes: ImmutableList, val isRefreshing: Boolean, val showAreaLabel: Boolean, @Redacted val eventSink: (RecipesEvent.Success) -> Unit, ) : RecipesState } +@Immutable sealed interface RecipesScreen : Screen { @CircuitSerializable(AppScope::class) data class ByCategory(val category: String) : RecipesScreen diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchPresenter.kt index f5cc5b7..0c4d6db 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchPresenter.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.SearchBarValue import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -33,6 +34,8 @@ import io.github.solcott.uistate.ContentStates3 import io.github.solcott.uistate.circuit.produceRetainedContentStates import io.github.solcott.uistate.contentStatesOf import kotlin.time.Duration.Companion.milliseconds +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -65,9 +68,9 @@ class SearchPresenter( .debounce(300.milliseconds) .produceRetainedContentStates( contentStatesOf( - emptyList(), - emptyList(), - emptyList(), + persistentListOf(), + persistentListOf(), + persistentListOf(), ) ) { query -> searchSuggestionsRepository.getSearchSuggestionsAsFlow(query) @@ -116,8 +119,13 @@ class SearchPresenter( * group answer for all three. */ typealias SearchSuggestionStates = - ContentStates3, List, List> + ContentStates3< + ImmutableList, + ImmutableList, + ImmutableList, + > +@Stable data class SearchState @OptIn(ExperimentalMaterial3Api::class) constructor( diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchTabPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchTabPresenter.kt index 48b8ea2..62984e6 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchTabPresenter.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/SearchTabPresenter.kt @@ -1,6 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiEvent @@ -46,6 +47,7 @@ sealed interface SearchTabEvent : CircuitUiEvent { data class GoTo(val screen: Screen) : SearchTabEvent } +@Immutable data class SearchTabState(@Redacted val eventSink: (SearchTabEvent) -> Unit) : CircuitUiState @CircuitSerializable(AppScope::class) data object SearchTabScreen : Screen diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/AreasProducer.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/AreasProducer.kt index dad1b75..2f22c38 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/AreasProducer.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/AreasProducer.kt @@ -6,11 +6,13 @@ import com.scottolcott.recipe.repository.AreaRepository import dev.zacsweers.metro.Inject import io.github.solcott.uistate.ContentState import io.github.solcott.uistate.circuit.produceRetainedContentState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Inject internal class AreasProducer(private val areasRepository: AreaRepository) { @Composable - fun produce(retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), retryTrigger) { areasRepository.getAreas() } + fun produce(retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), retryTrigger) { areasRepository.getAreas() } } diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/CategoriesProducer.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/CategoriesProducer.kt index 1edc618..558338b 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/CategoriesProducer.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/CategoriesProducer.kt @@ -6,11 +6,15 @@ import com.scottolcott.recipe.repository.CategoryRepository import dev.zacsweers.metro.Inject import io.github.solcott.uistate.ContentState import io.github.solcott.uistate.circuit.produceRetainedContentState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Inject internal class CategoriesProducer(private val categoryRepository: CategoryRepository) { @Composable - fun produce(retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), retryTrigger) { categoryRepository.getCategories() } + fun produce(retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), retryTrigger) { + categoryRepository.getCategories() + } } diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/IngredientsProducer.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/IngredientsProducer.kt index 7404517..cf9f318 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/IngredientsProducer.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/IngredientsProducer.kt @@ -6,13 +6,15 @@ import com.scottolcott.recipe.repository.IngredientRepository import dev.zacsweers.metro.Inject import io.github.solcott.uistate.ContentState import io.github.solcott.uistate.circuit.produceRetainedContentState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Inject internal class IngredientsProducer(private val ingredientsRepository: IngredientRepository) { @Composable - fun produce(retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), retryTrigger) { + fun produce(retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), retryTrigger) { ingredientsRepository.getIngredients() } } diff --git a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/RecipesProducer.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/RecipesProducer.kt index 5d5c5da..15f6af5 100644 --- a/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/RecipesProducer.kt +++ b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/producer/RecipesProducer.kt @@ -6,41 +6,56 @@ import com.scottolcott.recipe.repository.RecipeRepository import dev.zacsweers.metro.Inject import io.github.solcott.uistate.ContentState import io.github.solcott.uistate.circuit.produceRetainedContentState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Inject class RecipesProducer(private val recipeRepository: RecipeRepository) { @Composable - fun produceBySearchTerm(searchTerm: String, retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), searchTerm, retryTrigger) { + fun produceBySearchTerm( + searchTerm: String, + retryTrigger: Int, + ): ContentState> = + produceRetainedContentState(persistentListOf(), searchTerm, retryTrigger) { recipeRepository.searchRecipes(searchTerm) } @Composable - fun produceByCategory(category: String, retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), category, retryTrigger) { + fun produceByCategory(category: String, retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), category, retryTrigger) { recipeRepository.recipesByCategory(category) } // A Set, not vararg: the parameter is a produceRetainedContentState key, and an Array compares by // identity, so a vararg call site would allocate a fresh key on every recomposition and restart // the collection each pass. + // + // UnstableCollections wants an ImmutableSet, and for a composable that emits UI it would be + // right. This one returns a value, so the compiler marks it neither restartable nor skippable -- + // the stability report has it as a bare `fun`, against `restartable skippable` for the :ui + // composables. The parameter's stability is inert here, while an ImmutableSet would cost either a + // persistent-set copy per recomposition at the call site or a kotlinx-serialization serializer + // for RecipesScreen.ByIngredient, which has none for the immutable collections. + @Suppress("UnstableCollections") @Composable fun produceByIngredients( ingredients: Set, retryTrigger: Int, - ): ContentState> = - produceRetainedContentState(emptyList(), ingredients, retryTrigger) { + ): ContentState> = + produceRetainedContentState(persistentListOf(), ingredients, retryTrigger) { recipeRepository.recipesByIngredients(ingredients) } @Composable - fun produceByArea(area: String, retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), area, retryTrigger) { + fun produceByArea(area: String, retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), area, retryTrigger) { recipeRepository.recipesByArea(area) } @Composable - fun produceByFavorites(retryTrigger: Int): ContentState> = - produceRetainedContentState(emptyList(), retryTrigger) { recipeRepository.getFavoritesAsFlow() } + fun produceByFavorites(retryTrigger: Int): ContentState> = + produceRetainedContentState(persistentListOf(), retryTrigger) { + recipeRepository.getFavoritesAsFlow() + } } diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenterTest.kt index b5f0a54..bd0b49f 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/AreasPresenterTest.kt @@ -12,6 +12,8 @@ import io.github.solcott.dataresult.Outcome import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Clock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -25,16 +27,19 @@ private class AreasTestEnvironment( ) private class FakeAreaRepository : AreaRepository { - var getAreasHandler: () -> Flow>> = { inFlight() } + var getAreasHandler: () -> Flow>> = { inFlight() } - override fun getAreas(): Flow>> = getAreasHandler() + override fun getAreas(): Flow>> = getAreasHandler() override suspend fun countryFor(area: String): String? = null } val areasPresenterTests by testSuite { val areasFixture = testFixture { - listOf(Area("Italian", "Italy", Clock.System.now()), Area("Unknown", null, Clock.System.now())) + persistentListOf( + Area("Italian", "Italy", Clock.System.now()), + Area("Unknown", null, Clock.System.now()), + ) } val environmentFixture = testFixture { @@ -83,7 +88,7 @@ val areasPresenterTests by testSuite { val areas = areasFixture() // Driven emission by emission: a flow that emits both up front collapses into a single // recomposition, which would hide the transition this test is about. - val responses = MutableSharedFlow>>(replay = 1) + val responses = MutableSharedFlow>>(replay = 1) responses.emit(Outcome.Data(areas, Origin.Cache)) repository.getAreasHandler = { responses } diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenterTest.kt index 3bb540d..3e0569e 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/CategoriesPresenterTest.kt @@ -13,6 +13,8 @@ import io.github.solcott.dataresult.Outcome import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Clock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -25,21 +27,21 @@ private class CategoriesTestEnvironment( ) private class FakeCategoryRepository : CategoryRepository { - var getCategoriesHandler: () -> Flow>> = { inFlight() } + var getCategoriesHandler: () -> Flow>> = { inFlight() } - var getCategoriesByNameHandler: (String) -> Flow>> = { + var getCategoriesByNameHandler: (String) -> Flow>> = { getCategoriesHandler() } - override fun getCategories(): Flow>> = getCategoriesHandler() + override fun getCategories(): Flow>> = getCategoriesHandler() - override fun getCategories(nameFilter: String): Flow>> = + override fun getCategories(nameFilter: String): Flow>> = getCategoriesByNameHandler(nameFilter) } val categoriesPresenterTests by testSuite { val categoriesFixture = testFixture { - listOf(Category(CategoryId("1"), "Category 1", "thumb1", "desc1", Clock.System.now())) + persistentListOf(Category(CategoryId("1"), "Category 1", "thumb1", "desc1", Clock.System.now())) } val environmentFixture = testFixture { diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenterTest.kt index 79e9640..3237d15 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/HomePresenterTest.kt @@ -4,6 +4,7 @@ import com.slack.circuit.test.FakeNavigator import com.slack.circuit.test.test import de.infix.testBalloon.framework.core.testSuite import kotlin.test.assertEquals +import kotlinx.collections.immutable.persistentListOf private fun presenterFor(screen: HomeScreen) = HomePresenter(screen, FakeNavigator(screen)) @@ -27,7 +28,7 @@ val homePresenterTests by testSuite { test("every tab is offered in pager order") { presenterFor(HomeScreen()).test { assertEquals( - listOf(CategoriesScreen, IngredientsScreen, AreasScreen), + persistentListOf(CategoriesScreen, IngredientsScreen, AreasScreen), awaitItem().tabScreens, ) } diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenterTest.kt index 82b5fc1..6e3cd14 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/IngredientsPresenterTest.kt @@ -13,6 +13,8 @@ import io.github.solcott.dataresult.Outcome import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Clock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -25,17 +27,18 @@ private class IngredientsTestEnvironment( ) private class FakeIngredientRepository : IngredientRepository { - var getIngredientsHandler: () -> Flow>> = { inFlight() } + var getIngredientsHandler: () -> Flow>> = { inFlight() } - override fun getIngredients(): Flow>> = getIngredientsHandler() + override fun getIngredients(): Flow>> = getIngredientsHandler() - override fun filterIngredientsByName(nameFilter: String): Flow>> = - getIngredientsHandler() + override fun filterIngredientsByName( + nameFilter: String + ): Flow>> = getIngredientsHandler() } val ingredientsPresenterTests by testSuite { val ingredientsFixture = testFixture { - listOf(Ingredient(IngredientId("1"), "Chicken", lastFetched = Clock.System.now())) + persistentListOf(Ingredient(IngredientId("1"), "Chicken", lastFetched = Clock.System.now())) } val environmentFixture = testFixture { diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenterTest.kt index e1950bf..620bdf1 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipeDetailsPresenterTest.kt @@ -12,6 +12,7 @@ import io.github.solcott.dataresult.Outcome import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Clock +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -55,16 +56,18 @@ private class FakeRecipeDetailsRepository : RecipeRepository { stored?.let { emit(Outcome.Data(it.copy(favorite = favorite), Origin.Cache)) } } - override fun recipesByIngredients(ingredients: Set): Flow>> = - emptyFlow() + override fun recipesByIngredients( + ingredients: Set + ): Flow>> = emptyFlow() - override fun searchRecipes(query: String): Flow>> = emptyFlow() + override fun searchRecipes(query: String): Flow>> = emptyFlow() - override fun recipesByCategory(category: String): Flow>> = emptyFlow() + override fun recipesByCategory(category: String): Flow>> = + emptyFlow() - override fun recipesByArea(area: String): Flow>> = emptyFlow() + override fun recipesByArea(area: String): Flow>> = emptyFlow() - override fun getFavoritesAsFlow(): Flow>> = emptyFlow() + override fun getFavoritesAsFlow(): Flow>> = emptyFlow() } private val screen = RecipeDetailsScreen(RecipeId("1")) diff --git a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenterTest.kt b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenterTest.kt index 99c15e8..96d90cf 100644 --- a/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenterTest.kt +++ b/domain/src/commonTest/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenterTest.kt @@ -13,6 +13,8 @@ import io.github.solcott.dataresult.Outcome import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Clock +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emitAll @@ -20,7 +22,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow private class FakeRecipeRepository : RecipeRepository { - val responses = MutableStateFlow>>(Outcome.Loading) + val responses = MutableStateFlow>>(Outcome.Loading) /** Counts how many times the ingredient flow was *collected*, not how many times it was built. */ var ingredientSubscriptions = 0 @@ -29,21 +31,24 @@ private class FakeRecipeRepository : RecipeRepository { var lastIngredients: Set? = null private set - override fun recipesByIngredients(ingredients: Set): Flow>> = flow { + override fun recipesByIngredients( + ingredients: Set + ): Flow>> = flow { ingredientSubscriptions++ lastIngredients = ingredients emitAll(responses) } - override fun searchRecipes(query: String): Flow>> = responses + override fun searchRecipes(query: String): Flow>> = responses - override fun recipesByCategory(category: String): Flow>> = emptyFlow() + override fun recipesByCategory(category: String): Flow>> = + emptyFlow() - override fun recipesByArea(area: String): Flow>> = emptyFlow() + override fun recipesByArea(area: String): Flow>> = emptyFlow() override fun getById(id: RecipeId): Flow> = emptyFlow() - override fun getFavoritesAsFlow(): Flow>> = emptyFlow() + override fun getFavoritesAsFlow(): Flow>> = emptyFlow() override suspend fun addFavorite(id: RecipeId) = Unit @@ -75,12 +80,12 @@ val recipesPresenterTests by testSuite { presenter.test { assertIs(awaitItem()) - repository.responses.value = Outcome.Data(listOf(recipe("1")), Origin.Network) + repository.responses.value = Outcome.Data(persistentListOf(recipe("1")), Origin.Network) assertIs(awaitItem()) repository.responses.value = Outcome.Data( - listOf(recipe("1"), recipe("2")), + persistentListOf(recipe("1"), recipe("2")), Origin.Cache, ) val state = assertIs(awaitItem()) @@ -106,7 +111,7 @@ val recipesPresenterTests by testSuite { presenter.test { assertIs(awaitItem()) - repository.responses.value = Outcome.Data(emptyList(), Origin.Cache) + repository.responses.value = Outcome.Data(persistentListOf(), Origin.Cache) val state = assertIs(awaitItem()) assertEquals(emptyList(), state.recipes) @@ -126,14 +131,14 @@ val recipesPresenterTests by testSuite { assertIs(awaitItem()) // Settled, so it is an answer: nothing else was coming. - repository.responses.value = Outcome.Data(emptyList(), Origin.Cache) + repository.responses.value = Outcome.Data(persistentListOf(), Origin.Cache) assertIs(awaitItem()) repository.responses.value = Outcome.Loading assertIs(awaitItem()) // One instance: `recipe()` stamps `lastFetched` with the current time. - val fresh = listOf(recipe("1")) + val fresh = persistentListOf(recipe("1")) repository.responses.value = Outcome.Data(fresh, Origin.Network) val state = assertIs(awaitItem()) assertEquals(fresh, state.recipes) @@ -150,7 +155,7 @@ val recipesPresenterTests by testSuite { presenter.test { assertIs(awaitItem()) - repository.responses.value = Outcome.Data(emptyList(), Origin.Cache) + repository.responses.value = Outcome.Data(persistentListOf(), Origin.Cache) assertIs(awaitItem()) repository.responses.value = Outcome.Loading diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10e4827..f17ccde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity = "1.13.0" -agp = "9.4.0" +agp = "9.4.1" #noinspection UnusedVersionCatalogEntry androidCompileSdk = "37" # min sdk required by metro-android due to integration with AppComponentFactory @@ -11,7 +11,7 @@ androidTargetSdk = "36" androidx-compose = "1.12.1" androidx-compose-material3 = "1.5.0-alpha28" appdirs = "1.5.0" -circuit = "0.38.0" +circuit = "0.39.0" coil = "3.6.2" composeAdaptive = "1.3.0-rc01" composeMultiplatform = "1.12.0" @@ -19,7 +19,7 @@ composeMultiplatformMaterial3 = "1.12.0-alpha03" composeTestManifest = "1.12.1" dataresult = "0.1.0-SNAPSHOT" datastore = "1.3.0-alpha11" -dependency-analysis = "3.19.1" +dependency-analysis = "3.19.2" junit = "4.13.2" junitVersion = "1.3.0" # The JDK that compiles the project and runs it: desktop app, tests, jpackage runtime image. @@ -37,7 +37,7 @@ kotlin = "2.4.20" kotlinx-coroutines = "1.11.0" kotlinx-serialization = "1.11.0" ksp = "2.3.12" -ktor = "3.5.2" +ktor = "3.6.0" metro = "1.4.3" room = "3.0.3" sqlite = "2.7.1" @@ -107,6 +107,7 @@ compose-material3-adaptive = { module = "org.jetbrains.compose.material3.adaptiv compose-material3-adaptive-desktop = { module = "org.jetbrains.compose.material3.adaptive:adaptive-desktop", version.ref = "composeAdaptive" } compose-material3-desktop = { module = "org.jetbrains.compose.material3:material3-desktop", version.ref = "composeMultiplatformMaterial3" } compose-material3-window-size = { module = "org.jetbrains.compose.material3:material3-window-size-class", version.ref = "composeMultiplatformMaterial3" } +compose-runtime-annotations = { module = "androidx.compose.runtime:runtime-annotation", version.ref = "androidx-compose" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-ui-desktop = { module = "org.jetbrains.compose.ui:ui-desktop", version.ref = "composeMultiplatform" } compose-ui-graphics = { module = "org.jetbrains.compose.ui:ui-graphics", version.ref = "composeMultiplatform" } diff --git a/model/build.gradle.kts b/model/build.gradle.kts index e7f9ccd..c5b7dda 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -5,4 +5,14 @@ plugins { alias(libs.plugins.kotlinx.serialization) } -kotlin { sourceSets { commonMain { dependencies { api(libs.kotlin.serialization.core) } } } } +kotlin { + sourceSets { + commonMain { + dependencies { + api(libs.kotlin.serialization.core) + api(libs.kotlinx.collections.immutable) + implementation(libs.compose.runtime.annotations) + } + } + } +} diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Area.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Area.kt index 8cc74c4..48d4845 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Area.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Area.kt @@ -1,5 +1,6 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import kotlin.time.Instant import kotlinx.serialization.Serializable @@ -10,4 +11,6 @@ import kotlinx.serialization.Serializable * [country] is nullable because `list.php?a=list` declares `strCountry` nullable and does not * return it for every row. */ -@Serializable data class Area(val area: String, val country: String?, val lastFetched: Instant) +@Serializable +@Immutable +data class Area(val area: String, val country: String?, val lastFetched: Instant) diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Category.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Category.kt index 9d2e30f..4a4764d 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Category.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Category.kt @@ -1,5 +1,6 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import io.github.solcott.kmp.parcelize.Parcelable import io.github.solcott.kmp.parcelize.Parcelize import kotlin.jvm.JvmInline @@ -9,6 +10,7 @@ import kotlinx.serialization.Serializable @Parcelize @JvmInline @Serializable value class CategoryId(val value: String) : Parcelable @Serializable +@Immutable data class Category( val id: CategoryId, val name: String, diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Ingredient.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Ingredient.kt index fae4cff..c2490fa 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Ingredient.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Ingredient.kt @@ -1,9 +1,11 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import kotlin.time.Instant import kotlinx.serialization.Serializable @Serializable +@Immutable data class Ingredient( val id: IngredientId, val name: String, diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/IngredientId.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/IngredientId.kt index 379f042..6cecd72 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/IngredientId.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/IngredientId.kt @@ -1,8 +1,9 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import io.github.solcott.kmp.parcelize.Parcelable import io.github.solcott.kmp.parcelize.Parcelize import kotlin.jvm.JvmInline import kotlinx.serialization.Serializable -@JvmInline @Serializable @Parcelize value class IngredientId(val id: String) : Parcelable +@Immutable @JvmInline @Serializable @Parcelize value class IngredientId(val id: String) : Parcelable diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt index c91cbb4..17c78bc 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt @@ -1,7 +1,11 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import kotlin.time.Instant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +@Immutable data class Recipe( val id: RecipeId, val name: String, @@ -13,20 +17,21 @@ data class Recipe( val lastFetched: Instant, ) +@Immutable data class RecipeDetails( val alternateName: String? = null, val instructions: String, - val tags: List = emptyList(), + val tags: ImmutableList = persistentListOf(), val youtube: String? = null, val source: String? = null, val imageSource: String? = null, val creativeCommonsConfirmed: String? = null, val dateModified: String? = null, - val ingredients: List = emptyList(), + val ingredients: ImmutableList = persistentListOf(), val lastFetched: Instant, ) -data class RecipeIngredient(val ingredient: String, val measure: String) +@Immutable data class RecipeIngredient(val ingredient: String, val measure: String) /** * Canonical form of an ingredient name, used both for the indexed lookup column in storage and for diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeId.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeId.kt index 00a52fd..b0a3fbe 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeId.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeId.kt @@ -1,8 +1,9 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import io.github.solcott.kmp.parcelize.Parcelable import io.github.solcott.kmp.parcelize.Parcelize import kotlin.jvm.JvmInline import kotlinx.serialization.Serializable -@JvmInline @Serializable @Parcelize value class RecipeId(val id: String) : Parcelable +@Immutable @JvmInline @Serializable @Parcelize value class RecipeId(val id: String) : Parcelable diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeKey.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeKey.kt deleted file mode 100644 index 4dfeefe..0000000 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeKey.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.scottolcott.recipe.model - -// TODO move to different module diff --git a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt index 4a5a831..f2a9138 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt @@ -1,8 +1,10 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable @Serializable +@Immutable sealed interface SearchSuggestion { @Serializable data class QuerySuggestion(val query: String) : SearchSuggestion diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt index dda725d..1506e35 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt @@ -17,6 +17,8 @@ import io.github.solcott.dataresult.store5.asOutcomes import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -31,7 +33,7 @@ import org.mobilenativefoundation.store.store5.StoreReadRequest interface AreaRepository { - fun getAreas(): Flow>> + fun getAreas(): Flow>> /** * The country [area] names, or `null` if the list does not know this area or could not be loaded. @@ -63,9 +65,11 @@ internal class AreaRepositoryImpl( // every read is the whole list, and Unit says so rather than a sealed type with one member. private val fetcher: Fetcher> = Fetcher.of { api.getAreas().meals.orEmpty() } - private val sourceOfTruth: SourceOfTruth, List> = + private val sourceOfTruth: SourceOfTruth, ImmutableList> = SourceOfTruth.of( - reader = { dao.getAllAreasAsFlow().map { entities -> entities.map { it.toArea() } } }, + reader = { + dao.getAllAreasAsFlow().map { entities -> entities.map { it.toArea() }.toImmutableList() } + }, writer = { _, local -> // Replace rather than upsert: an upsert would leave an area dropped upstream in the grid // forever. CategoryRepositoryImpl clears the table the same way. @@ -77,8 +81,8 @@ internal class AreaRepositoryImpl( deleteAll = { dao.deleteAll() }, ) - private val converter: Converter, List, List> = - Converter.Builder, List, List>() + private val converter: Converter, List, ImmutableList> = + Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() dtos.map { AreaEntity(it.area, it.country, lastFetched) } @@ -88,10 +92,10 @@ internal class AreaRepositoryImpl( } .build() - private val store: Store> = + private val store: Store> = StoreBuilder.from(fetcher, sourceOfTruth, converter).build() - override fun getAreas(): Flow>> { + override fun getAreas(): Flow>> { return fetchHistoryDataStore.refreshNeeded(cacheExpiration).flatMapLatest { refresh -> // `fetching` holds back a first read of `[]`: a key never fetched, not an empty result. store diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt index e10ce35..799c907 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt @@ -18,6 +18,8 @@ import io.github.solcott.dataresult.store5.asOutcomes import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -31,9 +33,9 @@ import org.mobilenativefoundation.store.store5.StoreReadRequest interface CategoryRepository { - fun getCategories(): Flow>> + fun getCategories(): Flow>> - fun getCategories(nameFilter: String): Flow>> + fun getCategories(nameFilter: String): Flow>> } @SingleIn(AppScope::class) @@ -47,8 +49,9 @@ internal class CategoryRepositoryImpl( private val cacheExpiration: Duration = 6.hours, ) : CategoryRepository { - private val converter: Converter, List, List> = - Converter.Builder, List, List>() + private val converter: + Converter, List, ImmutableList> = + Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() dtos.map { CategoryEntity(it.id, it.name, it.thumbnail, it.description, lastFetched) } @@ -58,7 +61,7 @@ internal class CategoryRepositoryImpl( } .build() - private val store: Store> = + private val store: Store> = StoreBuilder.from( fetcher = createFetcher(), sourceOfTruth = createSourceOfTruth(), @@ -76,14 +79,16 @@ internal class CategoryRepositoryImpl( } private fun createSourceOfTruth(): - SourceOfTruth, List> { + SourceOfTruth, ImmutableList> { return SourceOfTruth.of( reader = { key: CategoriesKey -> when (key) { CategoriesKey.GetCategories -> categoryDao.getCategories() is CategoriesKey.FilterByName -> categoryDao.getCategories(key.nameFilter) }.map { categories -> - categories.map { Category(it.id, it.name, it.thumb, it.description, it.lastFetched) } + categories + .map { Category(it.id, it.name, it.thumb, it.description, it.lastFetched) } + .toImmutableList() } }, writer = { key: CategoriesKey, categories: List -> @@ -113,16 +118,16 @@ internal class CategoryRepositoryImpl( } @OptIn(ExperimentalCoroutinesApi::class) - override fun getCategories(): Flow>> { + override fun getCategories(): Flow>> { return loadCategoriesByKey(CategoriesKey.GetCategories) } - override fun getCategories(nameFilter: String): Flow>> { + override fun getCategories(nameFilter: String): Flow>> { return loadCategoriesByKey(CategoriesKey.FilterByName(nameFilter)) } @OptIn(ExperimentalCoroutinesApi::class) - private fun loadCategoriesByKey(key: CategoriesKey): Flow>> { + private fun loadCategoriesByKey(key: CategoriesKey): Flow>> { return fetchHistoryDataStore.refreshNeeded(key, cacheExpiration).flatMapLatest { refresh -> // `fetching` holds back a first read of `[]`: a key never fetched, not an empty result. store diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt index 8a9ef33..b07d130 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt @@ -18,6 +18,8 @@ import io.github.solcott.dataresult.store5.asOutcomes import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -31,9 +33,9 @@ import org.mobilenativefoundation.store.store5.StoreReadRequest interface IngredientRepository { - fun getIngredients(): Flow>> + fun getIngredients(): Flow>> - fun filterIngredientsByName(nameFilter: String): Flow>> + fun filterIngredientsByName(nameFilter: String): Flow>> } // detekt 2.0.0-alpha.6 false positive: UnusedPrivateProperty misses references made from lambdas @@ -60,7 +62,7 @@ internal class IngredientRepositoryImpl( } private val sourceOfTruth: - SourceOfTruth, List> = + SourceOfTruth, ImmutableList> = SourceOfTruth.of( reader = { key: IngredientsKey -> when (key) { @@ -88,8 +90,9 @@ internal class IngredientRepositoryImpl( deleteAll = { dao.deleteAll() }, ) - private val converter: Converter, List, List> = - Converter.Builder, List, List>() + private val converter: + Converter, List, ImmutableList> = + Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() dtos.map { dto -> @@ -103,18 +106,20 @@ internal class IngredientRepositoryImpl( } .build() - private val store: Store> = + private val store: Store> = StoreBuilder.from(fetcher, sourceOfTruth, converter).build() - override fun getIngredients(): Flow>> { + override fun getIngredients(): Flow>> { return loadIngredientsByKey(IngredientsKey.GetAll) } - override fun filterIngredientsByName(nameFilter: String): Flow>> { + override fun filterIngredientsByName( + nameFilter: String + ): Flow>> { return loadIngredientsByKey(IngredientsKey.FilterByName(nameFilter)) } - private fun loadIngredientsByKey(key: IngredientsKey): Flow>> { + private fun loadIngredientsByKey(key: IngredientsKey): Flow>> { return fetchHistoryDataStore.refreshNeeded(cacheExpiration).flatMapLatest { refresh -> // `fetching` holds back a first read of `[]`: a key never fetched, not an empty result. store @@ -124,9 +129,9 @@ internal class IngredientRepositoryImpl( } } - private fun Flow>.mapToIngredients(): Flow> = + private fun Flow>.mapToIngredients(): Flow> = map { entities -> - entities.map { it.toIngredient() } + entities.map { it.toIngredient() }.toImmutableList() } private fun IngredientEntity.toIngredient() = diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt index 0237853..f9bc757 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt @@ -4,9 +4,12 @@ import com.scottolcott.recipe.model.Recipe import com.scottolcott.recipe.model.RecipeDetails import com.scottolcott.recipe.model.RecipeIngredient import com.scottolcott.recipe.storage.entity.RecipeEntityWithDetail +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList -internal fun List.toModel(): List = map { recipe -> - recipe.toModel() +internal fun List.toModel(): ImmutableList { + return map { it.toModel() }.toImmutableList() } internal fun RecipeEntityWithDetail.toModel(): Recipe { @@ -14,7 +17,10 @@ internal fun RecipeEntityWithDetail.toModel(): Recipe { val detail = detail // @Relation does not guarantee row order, so restore the original slot order here. val ingredientsList = - ingredients.sortedBy { it.position }.map { RecipeIngredient(it.name, it.measure) } + ingredients + .sortedBy { it.position } + .map { RecipeIngredient(it.name, it.measure) } + .toImmutableList() return Recipe( id = recipe.id, @@ -33,8 +39,7 @@ internal fun RecipeEntityWithDetail.toModel(): Recipe { ?.splitToSequence(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } - ?.toList() - .orEmpty() + ?.toImmutableList() ?: persistentListOf() RecipeDetails( alternateName = alternateName, instructions = instructions, diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt index 51af0b2..0f95a88 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt @@ -21,6 +21,7 @@ import io.github.solcott.dataresult.store5.asOutcomes import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -32,17 +33,17 @@ import org.mobilenativefoundation.store.store5.StoreBuilder import org.mobilenativefoundation.store.store5.StoreReadRequest interface RecipeRepository { - fun searchRecipes(query: String): Flow>> + fun searchRecipes(query: String): Flow>> - fun recipesByCategory(category: String): Flow>> + fun recipesByCategory(category: String): Flow>> - fun recipesByIngredients(ingredients: Set): Flow>> + fun recipesByIngredients(ingredients: Set): Flow>> - fun recipesByArea(area: String): Flow>> + fun recipesByArea(area: String): Flow>> fun getById(id: RecipeId): Flow> - fun getFavoritesAsFlow(): Flow>> + fun getFavoritesAsFlow(): Flow>> suspend fun addFavorite(id: RecipeId) @@ -65,7 +66,7 @@ internal class RecipeRepositoryImpl( StoreBuilder.from(createFetcher(), createSourceOfTruth()).build() @OptIn(ExperimentalCoroutinesApi::class) - override fun searchRecipes(query: String): Flow>> { + override fun searchRecipes(query: String): Flow>> { val key = RecipesKey.Query(query.trim()) return fetchHistoryDataStore .refreshNeeded(key, cacheExpiration) @@ -81,7 +82,7 @@ internal class RecipeRepositoryImpl( } @OptIn(ExperimentalCoroutinesApi::class) - override fun recipesByCategory(category: String): Flow>> { + override fun recipesByCategory(category: String): Flow>> { val key = RecipesKey.ByCategory(category) return fetchHistoryDataStore .refreshNeeded(key, cacheExpiration) @@ -95,7 +96,9 @@ internal class RecipeRepositoryImpl( } @OptIn(ExperimentalCoroutinesApi::class) - override fun recipesByIngredients(ingredients: Set): Flow>> { + override fun recipesByIngredients( + ingredients: Set + ): Flow>> { val key = RecipesKey.ByIngredient.of(ingredients) return fetchHistoryDataStore .refreshNeeded(key, cacheExpiration) @@ -109,7 +112,7 @@ internal class RecipeRepositoryImpl( } @OptIn(ExperimentalCoroutinesApi::class) - override fun recipesByArea(area: String): Flow>> { + override fun recipesByArea(area: String): Flow>> { val key = RecipesKey.ByArea(area) return fetchHistoryDataStore .refreshNeeded(key, cacheExpiration) @@ -137,7 +140,7 @@ internal class RecipeRepositoryImpl( } @OptIn(ExperimentalCoroutinesApi::class) - override fun getFavoritesAsFlow(): Flow>> { + override fun getFavoritesAsFlow(): Flow>> { return recipeStore .stream(StoreReadRequest.cached(RecipesKey.Favorites, false)) .logErrors(logger, "Error getting recipe favorites") @@ -257,4 +260,4 @@ private const val HYDRATION_CONCURRENCY = 4 * A wrapper around the list rather than the list itself because Store needs a single output type * for every key, including the ones that read a single recipe. */ -data class RecipeResponse(val recipes: List) +data class RecipeResponse(val recipes: ImmutableList) diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/SearchSuggestionsRepository.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/SearchSuggestionsRepository.kt index ed13e15..166b0bd 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/SearchSuggestionsRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/SearchSuggestionsRepository.kt @@ -13,6 +13,8 @@ import io.github.solcott.dataresult.Origin import io.github.solcott.dataresult.Outcome import io.github.solcott.dataresult.Outcomes3 import io.github.solcott.dataresult.combineOutcomes +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -22,7 +24,7 @@ import kotlinx.coroutines.flow.map * ingredients. Destructure it to name them -- `val (history, categories, ingredients) = outcomes`. */ typealias SearchSuggestionsOutcomes = - Outcomes3, List, List> + Outcomes3, ImmutableList, ImmutableList> interface SearchSuggestionsRepository { @@ -58,15 +60,15 @@ internal class SearchSuggestionsRepositoryImpl( ingredientRepository.filterIngredientsByName(query), ) - private fun storedSuggestions(query: String): Flow>> = + private fun storedSuggestions(query: String): Flow>> = suggestionsDataStore.suggestions .map { stored -> stored.suggestions .filter { suggestion -> suggestion.text.startsWith(query.trim()) } .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.text }) } - .map, Outcome>> { - Outcome.Data(it, Origin.Cache) + .map, Outcome>> { + Outcome.Data(it.toImmutableList(), Origin.Cache) } // Caught here rather than left to cancel the group: a store that cannot be read should cost // the recents section, not the categories and ingredients beside it. diff --git a/settings.gradle.kts b/settings.gradle.kts index 371571c..40a3bd6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ pluginManagement { plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +// includeBuild("../kmp-dataresult") @Suppress("UnstableApiUsage") dependencyResolutionManagement { // repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index abd89f8..d6be321 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -6,6 +6,7 @@ import dev.zacsweers.metro.gradle.ExperimentalMetroGradleApi plugins { id("kmp.library") + id("compose.stability") alias(libs.plugins.metro) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.compose) diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index cdda5b0..e3b5fd3 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -4,6 +4,7 @@ import dev.zacsweers.metro.gradle.ExperimentalMetroGradleApi plugins { id("kmp.library") + id("compose.stability") alias(libs.plugins.metro) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.compose) diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt index 6240f6e..0d07afd 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt @@ -1,5 +1,8 @@ package com.scottolcott.recipe +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -23,13 +26,14 @@ import androidx.compose.ui.input.key.type * Web deliberately does not use this — the browser already maps these chords to history back, which * `BrowserHistoryEffect` turns into navigation. Handling them again would pop twice. */ +@Stable class BackShortcutHost { - internal var onBack: (() -> Boolean)? = null + internal val onBack: MutableState<(() -> Boolean)?> = mutableStateOf(null) /** * Pops one screen. Returns whether anything moved, so the caller can leave the key unconsumed. */ - fun requestBack(): Boolean = onBack?.invoke() == true + fun requestBack(): Boolean = onBack.value?.invoke() == true } /** diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeApp.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeApp.kt index 9bb165c..ed952e3 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeApp.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeApp.kt @@ -2,12 +2,12 @@ package com.scottolcott.recipe import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.window.core.layout.WindowSizeClass import com.scottolcott.recipe.domain.AppDesign import com.scottolcott.recipe.domain.AppInput import com.scottolcott.recipe.domain.LocalWindowSizeClass @@ -35,7 +35,7 @@ fun RecipeApp( subCircuit: SubCircuit, modifier: Modifier = Modifier, onRootPop: (result: PopResult?) -> Unit = {}, - windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfoV2().windowSizeClass, + adaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2(), initialScreen: Screen? = null, backShortcutHost: BackShortcutHost? = null, design: AppDesign = if (isIos()) AppDesign.Cupertino else AppDesign.Material, @@ -45,7 +45,7 @@ fun RecipeApp( // tokens and the branches below them can never disagree about which design is in force. RecipeAppTheme(design = design, input = input) { CompositionLocalProvider( - LocalWindowSizeClass provides windowSizeClass, + LocalWindowSizeClass provides adaptiveInfo.windowSizeClass, LocalDeepLinkScreen provides initialScreen, LocalBackShortcutHost provides backShortcutHost, ) { diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt index 540f82e..f7a29f8 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt @@ -62,6 +62,8 @@ import com.slack.circuit.runtime.screen.Screen import com.slack.circuit.sharedelements.SharedElementTransitionLayout import com.slack.circuitx.gesturenavigation.GestureNavigationDecorationFactory import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -169,7 +171,10 @@ private fun rememberCollapsingTitleBehavior(currentScreen: Screen?): TopAppBarSc /** The tab bar, growing and shrinking the slot rather than popping in and out of it. */ @Composable -private fun ScaffoldBottomBar(state: RecipeScaffoldState, destinations: List) { +private fun ScaffoldBottomBar( + state: RecipeScaffoldState, + destinations: ImmutableList, +) { AnimatedVisibility( state.navigationLayout == NavigationLayout.BottomBar, enter = expandVertically(), @@ -192,7 +197,7 @@ private fun ScaffoldBottomBar(state: RecipeScaffoldState, destinations: List = +private fun rememberAppDestinations(includeSearch: Boolean): ImmutableList = remember(includeSearch) { buildList { add( @@ -222,12 +227,13 @@ private fun rememberAppDestinations(includeSearch: Boolean): List, + destinations: ImmutableList, modifier: Modifier = Modifier, ) { // Selection follows the section the current screen sits under, not the current record itself, so @@ -264,11 +270,11 @@ private fun RecipeNavigationRail( private fun BackShortcutEffect(state: RecipeScaffoldState) { val host = LocalBackShortcutHost.current ?: return DisposableEffect(host, state) { - host.onBack = { + host.onBack.value = { // Reports whether it moved, so at the root the key falls through unconsumed rather than // being swallowed. state.canGoBack.also { if (it) state.eventSink(RecipeScaffoldEvent.Back) } } - onDispose { host.onBack = null } + onDispose { host.onBack.value = null } } } diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/HomeScreen.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/HomeScreen.kt index 65749c1..cb71192 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/HomeScreen.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/HomeScreen.kt @@ -32,6 +32,7 @@ import com.scottolcott.recipe.ui.design.AppSegmentedControl import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent import dev.zacsweers.metro.AppScope +import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.stringResource @Composable @@ -59,7 +60,7 @@ fun HomeScreen(state: HomeState, modifier: Modifier = Modifier) { Column(modifier = modifier.fillMaxSize()) { if (isCupertino) { AppSegmentedControl( - options = state.tabScreens.map { it.label() }, + options = state.tabScreens.map { it.label() }.toImmutableList(), selectedIndex = state.selectedIndex, onSelect = { state.eventSink(HomeEvent.TabSelected(state.tabScreens[it])) }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppNavigationBar.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppNavigationBar.kt index dc3270a..9d5db7c 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppNavigationBar.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppNavigationBar.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.scottolcott.recipe.domain.isCupertino import com.slack.circuit.runtime.screen.Screen +import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -67,7 +68,7 @@ import org.jetbrains.compose.resources.stringResource */ @Composable fun AppNavigationBar( - destinations: List, + destinations: ImmutableList, selected: Screen?, onSelect: (Screen) -> Unit, modifier: Modifier = Modifier, @@ -81,7 +82,7 @@ fun AppNavigationBar( @Composable private fun MaterialNavigationBar( - destinations: List, + destinations: ImmutableList, selected: Screen?, onSelect: (Screen) -> Unit, modifier: Modifier = Modifier, @@ -140,7 +141,7 @@ private fun MaterialNavigationBar( */ @Composable private fun CupertinoTabBar( - destinations: List, + destinations: ImmutableList, selected: Screen?, onSelect: (Screen) -> Unit, modifier: Modifier = Modifier, diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppSegmentedControl.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppSegmentedControl.kt index f63b6ba..592a4d5 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppSegmentedControl.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/design/AppSegmentedControl.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList /** * An iOS segmented control: a grey track with a single light pill that slides to the selection. @@ -44,7 +45,7 @@ import androidx.compose.ui.unit.dp */ @Composable fun AppSegmentedControl( - options: List, + options: ImmutableList, selectedIndex: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier, diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeDetailsScreen.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeDetailsScreen.kt index 1a7056b..f527844 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeDetailsScreen.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeDetailsScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -76,6 +75,7 @@ import com.slack.circuit.codegen.annotations.CircuitInject import dev.zacsweers.metro.AppScope import io.ktor.http.Url import kotlin.time.Clock +import kotlinx.collections.immutable.persistentListOf import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -251,18 +251,17 @@ private fun RecipeMetaInfo( modifier: Modifier = Modifier, ) { val details = recipe.details - val uriHandler = LocalUriHandler.current - Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { - RecipeTags(details?.tags.orEmpty()) + RecipeTags(details?.tags ?: persistentListOf()) RecipeCategoryAndArea(recipe, eventSink) - RecipeSources(details, uriHandler) + RecipeSources(details) } } @Suppress("UnusedReceiverParameter") @Composable -private fun ColumnScope.RecipeSources(details: RecipeDetails?, uriHandler: UriHandler) { +private fun ColumnScope.RecipeSources(details: RecipeDetails?) { + val uriHandler = LocalUriHandler.current val source = details?.source if (!source.isNullOrBlank()) { AssistChip( @@ -360,14 +359,14 @@ private fun RecipeDetailsPreview() { | Add the cooked vegetables and rice to the casserole dish with the chicken. Add most of the remaining sauce, reserving a bit to drizzle over the top when serving. Gently toss everything together in the casserole dish until combined. Return to oven and cook 15 minutes. Remove from oven and let stand 5 minutes before serving. Drizzle each serving with remaining sauce. Enjoy! """ .trimMargin(), - tags = listOf("Meat", "Casserole"), + tags = persistentListOf("Meat", "Casserole"), youtube = "https://www.youtube.com/watch?v=4aZr5hZXP_s", source = null, imageSource = null, creativeCommonsConfirmed = null, dateModified = null, ingredients = - listOf( + persistentListOf( RecipeIngredient("soy sauce", "3/4 cup"), RecipeIngredient("water", "1/2 cup"), RecipeIngredient("brown sugar", "1/4 cup"), diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeTags.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeTags.kt index 7b52314..f091d2d 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeTags.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/recipe/RecipeTags.kt @@ -7,10 +7,11 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalFlexBoxApi::class) @Composable -fun RecipeTags(tags: List, modifier: Modifier = Modifier) { +fun RecipeTags(tags: ImmutableList, modifier: Modifier = Modifier) { if (tags.isNotEmpty()) { FlexBox(config = { gap(8.dp) }, modifier = modifier) { for (tag in tags) { diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/search/SearchSuggestions.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/search/SearchSuggestions.kt index b6b05ea..9e5a9f9 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/search/SearchSuggestions.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/ui/search/SearchSuggestions.kt @@ -58,6 +58,7 @@ import com.scottolcott.recipe.ui.delete_24px import com.scottolcott.recipe.ui.history_24px import com.scottolcott.recipe.ui.ingredients import com.scottolcott.recipe.ui.recent +import kotlinx.collections.immutable.ImmutableList import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -110,7 +111,7 @@ private fun LazyListScope.sectionHeader( } private fun LazyListScope.historySection( - history: List, + history: ImmutableList, searchText: TextFieldState, listState: LazyListState, onSearch: (SearchSuggestion) -> Unit, @@ -179,7 +180,7 @@ private fun LazyListScope.historySection( } private fun LazyListScope.categorySection( - categories: List, + categories: ImmutableList, listState: LazyListState, onSearch: (SearchSuggestion) -> Unit, ) { @@ -199,7 +200,7 @@ private fun LazyListScope.categorySection( } private fun LazyListScope.ingredientSection( - ingredients: List, + ingredients: ImmutableList, listState: LazyListState, onSearch: (SearchSuggestion) -> Unit, ) { diff --git a/ui/src/webMain/kotlin/com/scottolcott/recipe/BrowserHistoryEffect.web.kt b/ui/src/webMain/kotlin/com/scottolcott/recipe/BrowserHistoryEffect.web.kt index a9f03d5..da64b5b 100644 --- a/ui/src/webMain/kotlin/com/scottolcott/recipe/BrowserHistoryEffect.web.kt +++ b/ui/src/webMain/kotlin/com/scottolcott/recipe/BrowserHistoryEffect.web.kt @@ -3,7 +3,12 @@ package com.scottolcott.recipe import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import com.scottolcott.recipe.domain.navigation.toUrlPath import com.scottolcott.recipe.domain.presenter.HomeScreen @@ -242,18 +247,19 @@ internal expect fun replaceDepth(depth: Int, url: String) /** Reads the depth previously stored in `history.state`, or `0` if absent/unparseable. */ internal expect fun historyDepth(): Int +@Stable private class BrowserNavState(initialDepth: Int) { - var depth: Int = initialDepth + var depth by mutableIntStateOf(initialDepth) /** * Upcoming `popstate` events to silently ignore (we called [org.w3c.dom.History.go] / back / * forward). */ - var pendingPopStateIgnore: Int = 0 + var pendingPopStateIgnore by mutableIntStateOf(0) /** Upcoming [snapshotFlow] emissions to silently ignore (browser initiated navigation). */ - var pendingSnapshotIgnore: Int = 0 + var pendingSnapshotIgnore by mutableIntStateOf(0) /** URL a root swap owes the depth-0 entry once its rewind settles; see [swapRoot]. */ - var pendingRootUrl: String? = null + var pendingRootUrl by mutableStateOf(null) } diff --git a/webApp/build.gradle.kts b/webApp/build.gradle.kts index 06b2a16..be10192 100644 --- a/webApp/build.gradle.kts +++ b/webApp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.metro) alias(libs.plugins.dependency.sorter) + id("compose.stability") id("dependency.analysis") id("detekt") }