From 23b86aebc5a4df6260a36fa585e1fc688bf97087 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 10:49:59 -0600 Subject: [PATCH 01/14] Update dependencies and add compose-runtime-annotations. * **Versions**: * Bumped `agp` to 9.4.1. * Bumped `circuit` to 0.39.0. * Bumped `dependency-analysis` to 3.19.2. * Bumped `ktor` to 3.6.0. * **Libraries**: * Added `compose-runtime-annotations` (`androidx.compose.runtime:runtime-annotation`) to the version catalog. Signed-off-by: Scott Olcott --- gradle/libs.versions.toml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10e4827..2738caf 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" } From 6a173ac1f506319d19c020488d417c872d11ddce Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 10:55:01 -0600 Subject: [PATCH 02/14] Mark UI states and models with Compose stability annotations. * **Domain & Model**: * Added the `compose.runtime.annotations` and `kotlinx.collections.immutable` dependencies. * Annotated data models, `Screen`s, and `CircuitUiState`s with `@Immutable` to help the Compose compiler skip unnecessary recompositions. * **UI**: * Marked `BackShortcutHost` and `BrowserNavState` as `@Stable`. * Converted regular `var` properties in `BackShortcutHost` and `BrowserNavState` to `MutableState` delegates (e.g. `mutableStateOf`, `mutableIntStateOf`) so Compose correctly tracks their reads and writes. * **Desktop**: * Annotated `DesktopAppGraph` with `@Immutable`. Signed-off-by: Scott Olcott --- .../com/scottolcott/recipe/DesktopAppGraph.kt | 2 ++ domain/build.gradle.kts | 1 + .../recipe/domain/presenter/AreasPresenter.kt | 2 ++ .../recipe/domain/presenter/CategoriesPresenter.kt | 2 ++ .../recipe/domain/presenter/HomePresenter.kt | 2 ++ .../domain/presenter/IngredientsPresenter.kt | 2 ++ .../domain/presenter/RecipeDetailsPresenter.kt | 2 ++ .../domain/presenter/RecipeScaffoldPresenter.kt | 2 ++ .../recipe/domain/presenter/RecipesPresenter.kt | 3 +++ .../recipe/domain/presenter/SearchPresenter.kt | 2 ++ .../recipe/domain/presenter/SearchTabPresenter.kt | 2 ++ model/build.gradle.kts | 12 +++++++++++- .../kotlin/com/scottolcott/recipe/model/Area.kt | 5 ++++- .../com/scottolcott/recipe/model/Category.kt | 2 ++ .../com/scottolcott/recipe/model/Ingredient.kt | 2 ++ .../com/scottolcott/recipe/model/IngredientId.kt | 3 ++- .../kotlin/com/scottolcott/recipe/model/Recipe.kt | 5 ++++- .../com/scottolcott/recipe/model/RecipeId.kt | 3 ++- .../scottolcott/recipe/model/SearchSuggestion.kt | 5 ++++- .../com/scottolcott/recipe/BackShortcutHost.kt | 8 ++++++-- .../com/scottolcott/recipe/RecipeScaffoldScreen.kt | 4 ++-- .../scottolcott/recipe/BrowserHistoryEffect.web.kt | 14 ++++++++++---- 22 files changed, 71 insertions(+), 14 deletions(-) diff --git a/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt b/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt index b9c5f55..eaf9334 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.Immutable 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, ], ) +@Immutable interface DesktopAppGraph : AppGraph { @Provides diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 5be2b90..58d672b 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -46,6 +46,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..f9dba31 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 @@ -53,6 +54,7 @@ internal constructor(private val navigator: Navigator, private val areasProducer } } +@Immutable sealed interface AreasState : CircuitUiState { data object Loading : 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..f0d15b9 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 @@ -57,6 +58,7 @@ internal constructor( } } +@Immutable sealed interface CategoriesState : CircuitUiState { data object Loading : 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..4aabcb9 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.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -56,6 +57,7 @@ internal constructor(private val screen: HomeScreen, private val navigator: Navi } } +@Immutable data class HomeState( val selectedTabScreen: HomeTabScreen, val selectedIndex: Int, 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..6cb02a8 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 @@ -56,6 +57,7 @@ internal constructor( } } +@Immutable sealed interface IngredientsState : CircuitUiState { data object Loading : IngredientsState 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..6699b62 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 @@ -3,6 +3,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SearchBarValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -304,6 +305,7 @@ sealed interface RecipeScaffoldEvent : CircuitUiEvent { constructor(val searchBarValue: SearchBarValue) : RecipeScaffoldEvent } +@Immutable 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..88497ce 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 @@ -89,6 +90,7 @@ sealed interface RecipesEvent : CircuitUiEvent { } } +@Immutable sealed interface RecipesState : CircuitUiState { data object Loading : RecipesState @@ -109,6 +111,7 @@ sealed interface RecipesState : CircuitUiState { ) : 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..4f92ad3 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 @@ -8,6 +8,7 @@ import androidx.compose.material3.SearchBarState import androidx.compose.material3.SearchBarValue import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope @@ -118,6 +119,7 @@ class SearchPresenter( typealias SearchSuggestionStates = ContentStates3, List, List> +@Immutable 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/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..ffa38e7 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,9 @@ package com.scottolcott.recipe.model +import androidx.compose.runtime.Immutable import kotlin.time.Instant +@Immutable data class Recipe( val id: RecipeId, val name: String, @@ -13,6 +15,7 @@ data class Recipe( val lastFetched: Instant, ) +@Immutable data class RecipeDetails( val alternateName: String? = null, val instructions: String, @@ -26,7 +29,7 @@ data class RecipeDetails( 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/SearchSuggestion.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt index 4a5a831..3ae5436 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt @@ -1,12 +1,15 @@ 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 @Serializable data class CategorySuggestion(val category: Category) : SearchSuggestion - @Serializable data class IngredientSuggestion(val ingredient: Ingredient) : SearchSuggestion + @Serializable + data class IngredientSuggestion(val ingredient: Ingredient) : SearchSuggestion } diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt index 6240f6e..f09ef1e 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 var 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/RecipeScaffoldScreen.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt index 540f82e..5b51f1b 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt @@ -264,11 +264,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/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) } From 43fa9015d39f1750d7ae290b00007d446ad0c0c8 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 10:58:06 -0600 Subject: [PATCH 03/14] Replace List with ImmutableList for UI state and repositories. * **Domain**: * Changed Presenter UI states (`AreasState`, `CategoriesState`, `HomeState`, `IngredientsState`, `RecipesState`, `SearchSuggestionStates`) to hold `ImmutableList` instead of `List`. * Updated Producers to return `ContentState>` initialized with `persistentListOf()`. * Updated corresponding unit test fixtures and fake repositories to match the new signatures. * **Model**: * Changed `RecipeDetails` fields `tags` and `ingredients` to use `ImmutableList` and `persistentListOf()`. * **Repository**: * Updated `AreaRepository`, `CategoryRepository`, `IngredientRepository`, `RecipeRepository`, and `SearchSuggestionsRepository` to expose `Flow>>`. * Adjusted `Store`, `SourceOfTruth`, and `Converter` implementations to build and emit immutable collections using `toImmutableList()`. * **UI**: * Migrated composable parameters in `AppNavigationBar`, `AppSegmentedControl`, `HomeScreen`, `RecipeTags`, `RecipeDetailsScreen`, `RecipeScaffoldScreen`, and `SearchSuggestions` to require `ImmutableList`, ensuring Compose treats the lists as stable for skipping recompositions. Signed-off-by: Scott Olcott --- .../recipe/domain/presenter/AreasPresenter.kt | 3 +- .../domain/presenter/CategoriesPresenter.kt | 3 +- .../recipe/domain/presenter/HomePresenter.kt | 8 +-- .../domain/presenter/IngredientsPresenter.kt | 4 +- .../recipe/domain/presenter/ListContent.kt | 7 +-- .../domain/presenter/RecipesPresenter.kt | 5 +- .../domain/presenter/SearchPresenter.kt | 14 +++-- .../recipe/domain/producer/AreasProducer.kt | 6 ++- .../domain/producer/CategoriesProducer.kt | 8 ++- .../domain/producer/IngredientsProducer.kt | 6 ++- .../recipe/domain/producer/RecipesProducer.kt | 27 ++++++---- .../domain/presenter/AreasPresenterTest.kt | 13 +++-- .../presenter/CategoriesPresenterTest.kt | 12 +++-- .../domain/presenter/HomePresenterTest.kt | 3 +- .../presenter/IngredientsPresenterTest.kt | 13 +++-- .../presenter/RecipeDetailsPresenterTest.kt | 15 +++--- .../domain/presenter/RecipesPresenterTest.kt | 29 ++++++----- .../com/scottolcott/recipe/model/Recipe.kt | 6 ++- .../recipe/repository/AreaRepository.kt | 22 ++++---- .../recipe/repository/CategoryRepository.kt | 33 +++++++----- .../recipe/repository/IngredientRepository.kt | 50 +++++++++++------- .../recipe/repository/RecipeDtoExt.kt | 5 +- .../repository/RecipeEntityWithDetailExt.kt | 11 +++- .../recipe/repository/RecipeRepository.kt | 51 +++++++++++-------- .../repository/SearchSuggestionsRepository.kt | 10 ++-- .../recipe/RecipeScaffoldScreen.kt | 12 +++-- .../com/scottolcott/recipe/ui/HomeScreen.kt | 3 +- .../recipe/ui/design/AppNavigationBar.kt | 7 +-- .../recipe/ui/design/AppSegmentedControl.kt | 3 +- .../recipe/ui/recipe/RecipeDetailsScreen.kt | 17 ++++--- .../recipe/ui/recipe/RecipeTags.kt | 3 +- .../recipe/ui/search/SearchSuggestions.kt | 7 +-- 32 files changed, 263 insertions(+), 153 deletions(-) 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 f9dba31..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 @@ -17,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 @@ -63,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 f0d15b9..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 @@ -20,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 @@ -66,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 4aabcb9..8691c84 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 @@ -17,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 /** @@ -26,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 @@ -61,7 +63,7 @@ internal constructor(private val screen: HomeScreen, private val navigator: Navi 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 6cb02a8..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 @@ -17,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 @@ -59,14 +60,13 @@ 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/RecipesPresenter.kt b/domain/src/commonMain/kotlin/com/scottolcott/recipe/domain/presenter/RecipesPresenter.kt index 88497ce..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 @@ -20,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 @@ -66,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) @@ -104,7 +105,7 @@ 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, 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 4f92ad3..d03f7c4 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 @@ -34,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 @@ -66,9 +68,9 @@ class SearchPresenter( .debounce(300.milliseconds) .produceRetainedContentStates( contentStatesOf( - emptyList(), - emptyList(), - emptyList(), + persistentListOf(), + persistentListOf(), + persistentListOf(), ) ) { query -> searchSuggestionsRepository.getSearchSuggestionsAsFlow(query) @@ -117,7 +119,11 @@ class SearchPresenter( * group answer for all three. */ typealias SearchSuggestionStates = - ContentStates3, List, List> + ContentStates3< + ImmutableList, + ImmutableList, + ImmutableList, + > @Immutable data class SearchState 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..e601bc6 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,19 +6,24 @@ 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) } @@ -29,18 +34,20 @@ class RecipesProducer(private val recipeRepository: RecipeRepository) { 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/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt index ffa38e7..17c78bc 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/Recipe.kt @@ -2,6 +2,8 @@ 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( @@ -19,13 +21,13 @@ data class Recipe( 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, ) 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..4609744 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,21 +81,21 @@ 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) } + dtos.map { AreaEntity(it.area, it.country, lastFetched) }.toImmutableList() } .fromOutputToLocal { models -> - models.map { AreaEntity(it.area, it.country, it.lastFetched) } + models.map { AreaEntity(it.area, it.country, it.lastFetched) }.toImmutableList() } .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..4058aa7 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,18 +49,23 @@ 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) } + dtos + .map { CategoryEntity(it.id, it.name, it.thumbnail, it.description, lastFetched) } + .toImmutableList() } .fromOutputToLocal { dtos -> - dtos.map { CategoryEntity(it.id, it.name, it.thumb, it.description, it.lastFetched) } + dtos + .map { CategoryEntity(it.id, it.name, it.thumb, it.description, it.lastFetched) } + .toImmutableList() } .build() - private val store: Store> = + private val store: Store> = StoreBuilder.from( fetcher = createFetcher(), sourceOfTruth = createSourceOfTruth(), @@ -76,14 +83,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 +122,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..69387f8 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,33 +90,47 @@ 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 -> - IngredientEntity(dto.id, dto.name, dto.description, dto.type, dto.thumbnail, lastFetched) - } + dtos + .map { dto -> + IngredientEntity( + dto.id, + dto.name, + dto.description, + dto.type, + dto.thumbnail, + lastFetched, + ) + } + .toImmutableList() } .fromOutputToLocal { models -> - models.map { - IngredientEntity(it.id, it.name, it.description, it.type, it.thumbnail, it.lastFetched) - } + models + .map { + IngredientEntity(it.id, it.name, it.description, it.type, it.thumbnail, it.lastFetched) + } + .toImmutableList() } .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 +140,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/RecipeDtoExt.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt index 0a5ac34..b7a506c 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt @@ -10,6 +10,8 @@ import com.scottolcott.recipe.storage.entity.RecipeEntityWithDetail import com.scottolcott.recipe.storage.entity.RecipeIngredientEntity import kotlin.time.Clock import kotlin.time.Instant +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList private fun RecipeFullDto.toDetailEntity(lastFetched: Instant): RecipeDetailEntity { return RecipeDetailEntity( @@ -31,7 +33,7 @@ private fun RecipeFullDto.toDetailEntity(lastFetched: Instant): RecipeDetailEnti * with a name but no measure is kept, since the API leaves measures blank for things like "salt to * taste". Slot order is preserved in `position`. */ -private fun RecipeFullDto.toIngredientEntities(): List { +private fun RecipeFullDto.toIngredientEntities(): ImmutableList { return listOf( ingredient1 to measure1, ingredient2 to measure2, @@ -68,6 +70,7 @@ private fun RecipeFullDto.toIngredientEntities(): List { ) } } + .toImmutableList() } private fun RecipeFullDto.toEntityWithDetail(lastFetched: Instant): RecipeEntityWithDetail { 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..c59cd1f 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeEntityWithDetailExt.kt @@ -4,17 +4,23 @@ 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.toImmutableList -internal fun List.toModel(): List = map { recipe -> +internal fun List.toModel(): ImmutableList = map { recipe -> recipe.toModel() } + .toImmutableList() 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, @@ -35,6 +41,7 @@ internal fun RecipeEntityWithDetail.toModel(): Recipe { ?.filter { it.isNotEmpty() } ?.toList() .orEmpty() + .toImmutableList() 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..098f447 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,9 @@ 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.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -32,17 +35,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 +68,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 +84,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 +98,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 +114,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 +142,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") @@ -167,24 +172,28 @@ internal class RecipeRepositoryImpl( * still throws: an empty return is written back as a successful fetch and would suppress the * retry for a full [cacheExpiration]. */ - private suspend fun fetchByIngredients(key: RecipesKey.ByIngredient): List { + private suspend fun fetchByIngredients( + key: RecipesKey.ByIngredient + ): ImmutableList { val queried = key.ingredients.take(MAX_FILTER_INGREDIENTS) val summaries = recipeApi.getByIngredient(queried)?.meals.orEmpty() val ids = summaries.take(MAX_HYDRATED_RESULTS).map { it.id } - if (ids.isEmpty()) return emptyList() + if (ids.isEmpty()) return persistentListOf() val alreadyFresh = recipeDao.idsWithFreshDetail(ids, Clock.System.now().minus(cacheExpiration)).toSet() val stale = ids.filterNot { it in alreadyFresh } - return stale.mapConcurrentlyCatching( - concurrency = HYDRATION_CONCURRENCY, - onFailure = { id, error -> - logger.w(error) { "Skipping recipe $id: hydration lookup failed" } - }, - ) { id -> - recipeApi.getRecipe(id)?.meals?.firstOrNull() - } + return stale + .mapConcurrentlyCatching( + concurrency = HYDRATION_CONCURRENCY, + onFailure = { id, error -> + logger.w(error) { "Skipping recipe $id: hydration lookup failed" } + }, + ) { id -> + recipeApi.getRecipe(id)?.meals?.firstOrNull() + } + .toImmutableList() } private fun createFetcher(): Fetcher> { @@ -257,4 +266,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/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/RecipeScaffoldScreen.kt index 5b51f1b..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 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..67b793a 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,8 @@ 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 kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -251,18 +252,18 @@ 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()) + val tags = remember(details?.tags) { details?.tags.orEmpty().toImmutableList() } + RecipeTags(tags) 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 +361,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, ) { From ae3913861ba97d3382eb81b38a774a4d42b4cd7c Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 10:58:51 -0600 Subject: [PATCH 04/14] Update `RecipeApp` to accept `WindowAdaptiveInfo` instead of `WindowSizeClass`. Change the `RecipeApp` composable parameter to take the full `WindowAdaptiveInfo` (defaulting to `currentWindowAdaptiveInfoV2()`) rather than extracting the size class in the default argument. `LocalWindowSizeClass` now extracts its value from this new `adaptiveInfo` parameter. Signed-off-by: Scott Olcott --- .../commonMain/kotlin/com/scottolcott/recipe/RecipeApp.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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, ) { From d94865b83d7af51f76fb55fa3d0d28741f3c10b8 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 10:59:03 -0600 Subject: [PATCH 05/14] Remove empty RecipeKey.kt from the model module. The file contained only a package declaration and a TODO comment to move it to a different module. Signed-off-by: Scott Olcott --- .../kotlin/com/scottolcott/recipe/model/RecipeKey.kt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 model/src/commonMain/kotlin/com/scottolcott/recipe/model/RecipeKey.kt 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 From 76aa2a3484e3033f778c162d6e83fbf8d0e44816 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:00:30 -0600 Subject: [PATCH 06/14] Minor cleanups: format SearchSuggestion and add commented includeBuild. * **Build**: * Added a commented-out `includeBuild("../kmp-dataresult")` line to `settings.gradle.kts`. * **Model**: * Formatted `IngredientSuggestion` in `SearchSuggestion` to place the `@Serializable` annotation on the same line as the class declaration, matching the style of `CategorySuggestion`. Signed-off-by: Scott Olcott --- .../kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt | 3 +-- settings.gradle.kts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) 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 3ae5436..f2a9138 100644 --- a/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt +++ b/model/src/commonMain/kotlin/com/scottolcott/recipe/model/SearchSuggestion.kt @@ -10,6 +10,5 @@ sealed interface SearchSuggestion { @Serializable data class CategorySuggestion(val category: Category) : SearchSuggestion - @Serializable - data class IngredientSuggestion(val ingredient: Ingredient) : SearchSuggestion + @Serializable data class IngredientSuggestion(val ingredient: Ingredient) : SearchSuggestion } 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) From 747d9718f08ad9792e12181fcde1b27ec9421e63 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:01:00 -0600 Subject: [PATCH 07/14] Add Claude launch configuration for the WASM web app. Creates a `.claude/launch.json` file containing a `webApp-wasm` configuration. This setup uses `./gradlew` to execute the `:webApp:wasmJsBrowserDevelopmentRun` task on port 8080, and passes `-PopenBrowser=false` to prevent it from automatically opening a new browser window. Signed-off-by: Scott Olcott --- .claude/launch.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .claude/launch.json 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 + } + ] +} From 84b64903eaf9a1334aebcca7ea0bae6293a07d09 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:32:45 -0600 Subject: [PATCH 08/14] Replace @Immutable with @Stable and add explicit types. * **Desktop**: * Replaced the `@Immutable` annotation with `@Stable` on `DesktopAppGraph`. * **Domain**: * Replaced the `@Immutable` annotation with `@Stable` on `HomeState`, `RecipeScaffoldState`, and `SearchState`. * Added explicit type arguments (`SearchSuggestion`, `Category`, `Ingredient`) to empty `persistentListOf()` calls in `SearchPresenter`. Signed-off-by: Scott Olcott --- .../kotlin/com/scottolcott/recipe/DesktopAppGraph.kt | 4 ++-- .../recipe/domain/presenter/HomePresenter.kt | 4 ++-- .../recipe/domain/presenter/RecipeScaffoldPresenter.kt | 4 ++-- .../recipe/domain/presenter/SearchPresenter.kt | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt b/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt index eaf9334..f15b510 100644 --- a/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt +++ b/desktopApp/src/main/kotlin/com/scottolcott/recipe/DesktopAppGraph.kt @@ -1,6 +1,6 @@ package com.scottolcott.recipe -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import co.touchlab.kermit.Logger import co.touchlab.kermit.NoTagFormatter import co.touchlab.kermit.Severity @@ -27,7 +27,7 @@ import dev.zacsweers.metro.Provides NetworkProviders::class, ], ) -@Immutable +@Stable interface DesktopAppGraph : AppGraph { @Provides 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 8691c84..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,7 +1,7 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,7 +59,7 @@ internal constructor(private val screen: HomeScreen, private val navigator: Navi } } -@Immutable +@Stable data class HomeState( val selectedTabScreen: HomeTabScreen, val selectedIndex: Int, 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 6699b62..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 @@ -3,8 +3,8 @@ package com.scottolcott.recipe.domain.presenter import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SearchBarValue import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable 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 @@ -305,7 +305,7 @@ sealed interface RecipeScaffoldEvent : CircuitUiEvent { constructor(val searchBarValue: SearchBarValue) : RecipeScaffoldEvent } -@Immutable +@Stable data class RecipeScaffoldState( val navStack: NavStack, val navigator: Navigator, 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 d03f7c4..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 @@ -8,8 +8,8 @@ import androidx.compose.material3.SearchBarState import androidx.compose.material3.SearchBarValue import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable 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 @@ -68,9 +68,9 @@ class SearchPresenter( .debounce(300.milliseconds) .produceRetainedContentStates( contentStatesOf( - persistentListOf(), - persistentListOf(), - persistentListOf(), + persistentListOf(), + persistentListOf(), + persistentListOf(), ) ) { query -> searchSuggestionsRepository.getSearchSuggestionsAsFlow(query) @@ -125,7 +125,7 @@ typealias SearchSuggestionStates = ImmutableList, > -@Immutable +@Stable data class SearchState @OptIn(ExperimentalMaterial3Api::class) constructor( From 29e2c334bdec32a090ea2ed6c6c21b9c08b20ab0 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:33:11 -0600 Subject: [PATCH 09/14] Change `onBack` property from `var` to `val` in `BackShortcutHost`. The `onBack` property holds a `MutableState`, which manages its own internal mutability. The property reference itself does not need to be reassigned, so it is safer and more idiomatic to declare it as a `val`. Signed-off-by: Scott Olcott --- .../kotlin/com/scottolcott/recipe/BackShortcutHost.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt b/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt index f09ef1e..0d07afd 100644 --- a/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt +++ b/ui/src/commonMain/kotlin/com/scottolcott/recipe/BackShortcutHost.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.input.key.type */ @Stable class BackShortcutHost { - internal var onBack: MutableState<(() -> Boolean)?> = mutableStateOf(null) + internal val onBack: MutableState<(() -> Boolean)?> = mutableStateOf(null) /** * Pops one screen. Returns whether anything moved, so the caller can leave the key unconsumed. From 513b84717dd7ad89121d831c8889d5c0db0ddd86 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:38:15 -0600 Subject: [PATCH 10/14] Remove redundant ImmutableList conversions and allocations. * **Repository**: * Removed `.toImmutableList()` calls in the Store converters for `AreaRepository`, `CategoryRepository`, and `IngredientRepository`. The builders expect a standard `List`, so allocating an `ImmutableList` was unnecessary. * Changed `fetchByIngredients` and `toIngredientEntities` to return standard `List`s instead of `ImmutableList`s, replacing `persistentListOf()` with `emptyList()`. * Simplified comma-separated tag parsing in `RecipeEntityWithDetailExt` to use `?: persistentListOf()` instead of `.toList().orEmpty().toImmutableList()`. * **UI**: * Dropped the `remember` block wrapping `details?.tags.orEmpty().toImmutableList()` in `RecipeDetailsScreen`. The domain model already exposes tags as an `ImmutableList`, so a direct fallback to `persistentListOf()` is sufficient. Signed-off-by: Scott Olcott --- .../recipe/repository/AreaRepository.kt | 4 +-- .../recipe/repository/CategoryRepository.kt | 8 ++---- .../recipe/repository/IngredientRepository.kt | 23 +++++----------- .../recipe/repository/RecipeDtoExt.kt | 5 +--- .../repository/RecipeEntityWithDetailExt.kt | 10 +++---- .../recipe/repository/RecipeRepository.kt | 26 +++++++------------ .../recipe/ui/recipe/RecipeDetailsScreen.kt | 4 +-- 7 files changed, 26 insertions(+), 54 deletions(-) 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 4609744..1506e35 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/AreaRepository.kt @@ -85,10 +85,10 @@ internal class AreaRepositoryImpl( Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() - dtos.map { AreaEntity(it.area, it.country, lastFetched) }.toImmutableList() + dtos.map { AreaEntity(it.area, it.country, lastFetched) } } .fromOutputToLocal { models -> - models.map { AreaEntity(it.area, it.country, it.lastFetched) }.toImmutableList() + models.map { AreaEntity(it.area, it.country, it.lastFetched) } } .build() 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 4058aa7..799c907 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/CategoryRepository.kt @@ -54,14 +54,10 @@ internal class CategoryRepositoryImpl( Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() - dtos - .map { CategoryEntity(it.id, it.name, it.thumbnail, it.description, lastFetched) } - .toImmutableList() + dtos.map { CategoryEntity(it.id, it.name, it.thumbnail, it.description, lastFetched) } } .fromOutputToLocal { dtos -> - dtos - .map { CategoryEntity(it.id, it.name, it.thumb, it.description, it.lastFetched) } - .toImmutableList() + dtos.map { CategoryEntity(it.id, it.name, it.thumb, it.description, it.lastFetched) } } .build() 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 69387f8..b07d130 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/IngredientRepository.kt @@ -95,25 +95,14 @@ internal class IngredientRepositoryImpl( Converter.Builder, List, ImmutableList>() .fromNetworkToLocal { dtos -> val lastFetched = Clock.System.now() - dtos - .map { dto -> - IngredientEntity( - dto.id, - dto.name, - dto.description, - dto.type, - dto.thumbnail, - lastFetched, - ) - } - .toImmutableList() + dtos.map { dto -> + IngredientEntity(dto.id, dto.name, dto.description, dto.type, dto.thumbnail, lastFetched) + } } .fromOutputToLocal { models -> - models - .map { - IngredientEntity(it.id, it.name, it.description, it.type, it.thumbnail, it.lastFetched) - } - .toImmutableList() + models.map { + IngredientEntity(it.id, it.name, it.description, it.type, it.thumbnail, it.lastFetched) + } } .build() diff --git a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt index b7a506c..0a5ac34 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeDtoExt.kt @@ -10,8 +10,6 @@ import com.scottolcott.recipe.storage.entity.RecipeEntityWithDetail import com.scottolcott.recipe.storage.entity.RecipeIngredientEntity import kotlin.time.Clock import kotlin.time.Instant -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList private fun RecipeFullDto.toDetailEntity(lastFetched: Instant): RecipeDetailEntity { return RecipeDetailEntity( @@ -33,7 +31,7 @@ private fun RecipeFullDto.toDetailEntity(lastFetched: Instant): RecipeDetailEnti * with a name but no measure is kept, since the API leaves measures blank for things like "salt to * taste". Slot order is preserved in `position`. */ -private fun RecipeFullDto.toIngredientEntities(): ImmutableList { +private fun RecipeFullDto.toIngredientEntities(): List { return listOf( ingredient1 to measure1, ingredient2 to measure2, @@ -70,7 +68,6 @@ private fun RecipeFullDto.toIngredientEntities(): ImmutableList.toModel(): ImmutableList = map { recipe -> - recipe.toModel() +internal fun List.toModel(): ImmutableList { + return map { it.toModel() }.toImmutableList() } - .toImmutableList() internal fun RecipeEntityWithDetail.toModel(): Recipe { @@ -39,9 +39,7 @@ internal fun RecipeEntityWithDetail.toModel(): Recipe { ?.splitToSequence(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } - ?.toList() - .orEmpty() - .toImmutableList() + ?.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 098f447..0f95a88 100644 --- a/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt +++ b/repository/src/commonMain/kotlin/com/scottolcott/recipe/repository/RecipeRepository.kt @@ -22,8 +22,6 @@ import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -172,28 +170,24 @@ internal class RecipeRepositoryImpl( * still throws: an empty return is written back as a successful fetch and would suppress the * retry for a full [cacheExpiration]. */ - private suspend fun fetchByIngredients( - key: RecipesKey.ByIngredient - ): ImmutableList { + private suspend fun fetchByIngredients(key: RecipesKey.ByIngredient): List { val queried = key.ingredients.take(MAX_FILTER_INGREDIENTS) val summaries = recipeApi.getByIngredient(queried)?.meals.orEmpty() val ids = summaries.take(MAX_HYDRATED_RESULTS).map { it.id } - if (ids.isEmpty()) return persistentListOf() + if (ids.isEmpty()) return emptyList() val alreadyFresh = recipeDao.idsWithFreshDetail(ids, Clock.System.now().minus(cacheExpiration)).toSet() val stale = ids.filterNot { it in alreadyFresh } - return stale - .mapConcurrentlyCatching( - concurrency = HYDRATION_CONCURRENCY, - onFailure = { id, error -> - logger.w(error) { "Skipping recipe $id: hydration lookup failed" } - }, - ) { id -> - recipeApi.getRecipe(id)?.meals?.firstOrNull() - } - .toImmutableList() + return stale.mapConcurrentlyCatching( + concurrency = HYDRATION_CONCURRENCY, + onFailure = { id, error -> + logger.w(error) { "Skipping recipe $id: hydration lookup failed" } + }, + ) { id -> + recipeApi.getRecipe(id)?.meals?.firstOrNull() + } } private fun createFetcher(): Fetcher> { 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 67b793a..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 @@ -76,7 +76,6 @@ import dev.zacsweers.metro.AppScope import io.ktor.http.Url import kotlin.time.Clock import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource @@ -253,8 +252,7 @@ private fun RecipeMetaInfo( ) { val details = recipe.details Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { - val tags = remember(details?.tags) { details?.tags.orEmpty().toImmutableList() } - RecipeTags(tags) + RecipeTags(details?.tags ?: persistentListOf()) RecipeCategoryAndArea(recipe, eventSink) RecipeSources(details) } From c642334376688005e3f361fcea1288d3d1da31dd Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:38:59 -0600 Subject: [PATCH 11/14] Fix whitespace in libs.versions.toml. Corrected the spacing in the `compose-runtime-annotations` dependency declaration by removing an extra space before the `module` key and adding a missing space before the closing brace. Signed-off-by: Scott Olcott --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2738caf..f17ccde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -107,7 +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-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" } From 88914a1a0e0374382acd5605b458ee072dc0b4c9 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 11:39:29 -0600 Subject: [PATCH 12/14] Add Compose stability guidelines to CLAUDE.md. Document the rules for keeping Compose UI states stable and avoiding unnecessary recompositions. * **Collections**: Mandate `ImmutableList` for all UI-bound collections starting from the repository's public signature, while leaving entities and DTOs as standard `List`s. * **Annotations**: Clarify the distinction between `@Immutable` (for strictly value-based states) and `@Stable` (for states carrying snapshot-backed observable holders like `TextFieldState` or `NavStack`). * **Interfaces and Supertypes**: Note that `@Stable` does not propagate from supertypes, requiring explicit annotations on declared parameter types and all sealed `CircuitUiState` interfaces. Signed-off-by: Scott Olcott --- CLAUDE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index aaedf45..b609ab9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,32 @@ 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. + **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 From 2c26cc86be79aa0d7311633adadb360652818ec7 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 15:00:09 -0600 Subject: [PATCH 13/14] Enforce Compose stability with the compiler's own reports. The stability discipline in CLAUDE.md -- ImmutableList from the repository signature onward, @Immutable when it is true and @Stable when it is not, a marker on every sealed CircuitUiState -- was prose enforced by whoever remembered to read it. Nothing turned on the Compose compiler's metrics, so there was no way to check the claims against what the compiler actually inferred. The new `compose.stability` convention plugin sets reportsDestination and metricsDestination for the six modules that apply the Compose compiler plugin, and registers `composeStabilityCheck`, which fails on a class inferred `unstable` or a `restartable` composable that is not `skippable`. A `runtime` class passes: its stability turns on a generic argument settled at run time. Gradle's cross-project name matching fans the task out, so there is no aggregate task to maintain. Two properties of the Compose plugin shape the wiring: Only metricsDestination is per-target. reportsDestination is handed to every compilation verbatim, and a KMP module's targets share a Kotlin module name, so all six write the same `Recipes_ui-classes.txt` and the last one wins. The check depends on exactly one compile task, which leaves a single writer. That is also why it is not wired into `check` and why CI runs it before `build`. reportsDestination is registered as a compiler *input*. A compile task whose reports had been deleted stayed up to date and quietly wrote nothing -- four of six modules failed with "no reports found" on a healthy tree. Declaring the directory as an output of the designated compile task fixes that, and keeps that task the last writer after a full build. Triage found :ui, :domain, :shared and :desktopApp clean. The four allowlisted classes are the Metro graph impls for :app and :webApp and the two Android entry points, none of which is ever a composable parameter. DesktopAppGraph is the one graph that is, it already carries @Stable, and :desktopApp reports nothing. Detekt's UnstableCollections is on. RecipesProducer.produceByIngredients is the single @Suppress: it returns a value, so the compiler marks it neither restartable nor skippable and the parameter's stability is inert, while an ImmutableSet would cost a persistent-set copy per recomposition or a serializer for RecipesScreen.ByIngredient, which has none for immutable collections. Both checks were confirmed to fail when they should -- removing the @Suppress produced the detekt finding, and a scratch class with a `var` produced the unstable-class failure naming it. Co-Authored-By: Claude Opus 5 --- .claude/skills/verify/SKILL.md | 21 +- .github/workflows/build.yml | 14 ++ CLAUDE.md | 37 +++ app/build.gradle.kts | 1 + build-logic/build.gradle.kts | 1 + .../scottolcott/gradle/ComposeReportTarget.kt | 53 +++++ .../gradle/ComposeStabilityCheckTask.kt | 218 ++++++++++++++++++ .../main/kotlin/compose.stability.gradle.kts | 50 ++++ config/compose/unskippable-composables.txt | 9 + config/compose/unstable-classes.txt | 26 +++ config/detekt/detekt.yml | 2 +- desktopApp/build.gradle.kts | 1 + domain/build.gradle.kts | 1 + .../recipe/domain/producer/RecipesProducer.kt | 8 + shared/build.gradle.kts | 1 + ui/build.gradle.kts | 1 + webApp/build.gradle.kts | 1 + 17 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeReportTarget.kt create mode 100644 build-logic/src/main/kotlin/com/scottolcott/gradle/ComposeStabilityCheckTask.kt create mode 100644 build-logic/src/main/kotlin/compose.stability.gradle.kts create mode 100644 config/compose/unskippable-composables.txt create mode 100644 config/compose/unstable-classes.txt 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..badef6b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,5 +45,19 @@ 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 + + - name: Upload Compose reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: compose-reports + path: '**/build/reports/compose/**' + if-no-files-found: warn + - name: Build run: ./gradlew build diff --git a/CLAUDE.md b/CLAUDE.md index b609ab9..6aef848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,6 +233,43 @@ Annotate the *declared parameter type*: `@Stable` on a supertype does not propag 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. + +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/domain/build.gradle.kts b/domain/build.gradle.kts index 58d672b..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) 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 e601bc6..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 @@ -30,6 +30,14 @@ class RecipesProducer(private val recipeRepository: RecipeRepository) { // 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, 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/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") } From 10c83b454141b841a5e12c25137429dc2cc33546 Mon Sep 17 00:00:00 2001 From: Scott Olcott Date: Fri, 18 Sep 2026 15:12:57 -0600 Subject: [PATCH 14/14] Fix the Compose reports artifact upload on CI. The first run of the new workflow step failed. The Compose compiler names its reports after the Kotlin module, which here is the Gradle project path, so they come out as `Recipes:domain-classes.txt` -- and upload-artifact rejects a colon in a path, because NTFS does. Stage a renamed copy instead. It is staged immediately after the check rather than at the end of the job so the artifact is exactly what the check read; `build` recompiles the other targets over the same directory. Also mark the upload `continue-on-error`. It is diagnostics, and its failure took the whole job down and skipped `Build` -- a broken artifact upload must not gate the build it is meant to help diagnose. The Gradle steps themselves passed on that run, `Compose stability` included. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 23 ++++++++++++++++++++++- .gitignore | 5 ++++- CLAUDE.md | 4 +++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index badef6b..eb54599 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,12 +51,33 @@ jobs: - 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: '**/build/reports/compose/**' + path: compose-reports if-no-files-found: warn - name: 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 6aef848..d862d4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,7 +248,9 @@ 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 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: