From 573b4562c21fa256869bdbb2c0d2e78b2c2a271f Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Sun, 10 May 2026 12:01:54 +0200 Subject: [PATCH] Remove haze --- composeApp/build.gradle.kts | 5 - .../components/glass/IsDebugBuild.android.kt | 26 --- .../kotlin/dev/ulfrx/recipe/di/ShellModule.kt | 33 --- .../recipe/ui/components/dock/DockBar.kt | 2 +- .../components/dock/FloatingSearchButton.kt | 7 +- .../ui/components/glass/FlatGlassSurface.kt | 36 --- .../ui/components/glass/GlassBackdrop.kt | 12 +- .../ui/components/glass/GlassBackend.kt | 46 ---- .../ui/components/glass/GlassSurface.kt | 11 +- .../ui/components/glass/HazeGlassSurface.kt | 58 ----- .../ui/components/glass/IsDebugBuild.kt | 7 - .../recipe/ui/components/search/SearchPill.kt | 4 - .../ulfrx/recipe/ui/screens/shell/AppShell.kt | 4 +- .../dev/ulfrx/recipe/ui/theme/RecipeTheme.kt | 5 - .../dev/ulfrx/recipe/ComposeAppCommonTest.kt | 11 - .../dev/ulfrx/recipe/auth/AuthSessionTest.kt | 220 ------------------ .../auth/SecureAuthStateStoreContractTest.kt | 127 ---------- .../ulfrx/recipe/navigation/NavigationTest.kt | 62 ----- .../glass/GlassBackendOverrideTest.kt | 85 ------- .../ui/components/glass/GlassBackendTest.kt | 152 ------------ .../ui/screens/auth/LoginViewModelTest.kt | 162 ------------- .../pantry/PantrySearchViewModelTest.kt | 42 ---- .../recipes/RecipesSearchViewModelTest.kt | 62 ----- .../ui/screens/shell/AppShellGateTest.kt | 73 ------ .../ulfrx/recipe/user/UserRepositoryTest.kt | 134 ----------- .../ui/components/glass/IsDebugBuild.ios.kt | 8 - gradle/libs.versions.toml | 2 - 27 files changed, 12 insertions(+), 1384 deletions(-) delete mode 100644 composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt delete mode 100644 composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/user/UserRepositoryTest.kt delete mode 100644 composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index bb0def5..1411870 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -94,11 +94,6 @@ kotlin { implementation(libs.compose.unstyled) implementation(libs.compose.icons.lucide) implementation(libs.liquid) - implementation(libs.haze) - } - commonTest.dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutinesTest) } androidMain.dependencies { implementation(libs.androidx.activity.compose) diff --git a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt b/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt deleted file mode 100644 index 38fa44f..0000000 --- a/composeApp/src/androidMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.android.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import android.app.Application -import android.content.pm.ApplicationInfo - -/** - * Android actual: this module does not expose an app `BuildConfig` class on the - * Kotlin compile classpath, so read the runtime debuggable flag from the current - * application instead. This keeps release builds on the production path without - * requiring a build-file change outside this plan's ownership. - */ -actual val isDebugBuild: Boolean - get() = - currentApplication() - ?.applicationInfo - ?.flags - ?.and(ApplicationInfo.FLAG_DEBUGGABLE) != 0 - -@Suppress("PrivateApi") -private fun currentApplication(): Application? = - runCatching { - Class - .forName("android.app.ActivityThread") - .getMethod("currentApplication") - .invoke(null) as? Application - }.getOrNull() diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt index 5508849..b804896 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt @@ -1,9 +1,5 @@ package dev.ulfrx.recipe.di -import com.russhwolf.settings.Settings -import dev.ulfrx.recipe.ui.components.glass.GlassBackend -import dev.ulfrx.recipe.ui.components.glass.isDebugBuild -import dev.ulfrx.recipe.ui.components.glass.resolveGlassBackend import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel @@ -14,37 +10,8 @@ import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel import org.koin.dsl.module import org.koin.plugin.module.dsl.viewModel -/** - * Phase 2.1 (UI-03 / UI-04 / UI-09 / UI-10) — DI module for the app-shell layer. - * - * Registers: - * - 4 tab ViewModels (Planner / Recipes / Pantry / Shopping) — pure StateFlow, - * no dependencies this phase. Phase 5+ extends each to inject repositories. - * - 2 Search ViewModels (Recipes + Pantry) — pure StateFlow with nullable - * `searchSource: SearchSource? = null` per RESEARCH § Pattern 4. - * - 1 ShellViewModel — active-tab + search-open state machine. - * - 1 GlassBackend single — resolved at composition root from - * [resolveGlassBackend] (CONTEXT D-16 / D-17). Default backend is - * [GlassBackend.Liquid] — the iOS+Android primary path; debug builds may - * pick up a runtime override stored in `multiplatform-settings`. - * - * Settings binding: registered in platform-specific Koin modules - * (`auth/IosAuthModule.kt`, `auth/AndroidAuthModule.kt`) for use by - * SecureAuthStateStore — the same single binding is reused here. - */ val shellModule = module { - // Glass backend — resolved once at startup. Production builds short-circuit - // [resolveGlassBackend] via [isDebugBuild] = false; debug builds may pick up - // a runtime override stored in `multiplatform-settings`. - single { - resolveGlassBackend( - settings = get(), - isDebug = isDebugBuild, - default = GlassBackend.Liquid, - ) - } - // Shell-level state machine. viewModel() diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt index 9b76aec..bbd0cdf 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt @@ -52,7 +52,7 @@ import recipe.composeapp.generated.resources.search_close_a11y * [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with * [FastOutSlowInEasing] per UI-SPEC line 198. * - * Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are + * Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid API calls are * forbidden here per CLAUDE.md non-negotiable #10. * * Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224). diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt index 80a21b6..a4e4cc9 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Search @@ -30,10 +31,9 @@ import recipe.composeapp.generated.resources.search_open_a11y */ @Composable fun FloatingSearchButton( - onClick: () -> Unit, modifier: Modifier = Modifier, + onClick: () -> Unit = {}, ) { - val a11y = stringResource(Res.string.search_open_a11y) GlassSurface( modifier = modifier.size(56.dp), cornerRadius = 28.dp, @@ -49,7 +49,7 @@ fun FloatingSearchButton( ) { UnstyledIcon( imageVector = Lucide.Search, - contentDescription = a11y, + contentDescription = stringResource(Res.string.search_open_a11y), tint = RecipeTheme.colors.content, modifier = Modifier.size(24.dp), ) @@ -57,3 +57,4 @@ fun FloatingSearchButton( } } } + diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt deleted file mode 100644 index 55ff8c6..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/FlatGlassSurface.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp - -/** - * Flat translucent fallback with no blur. Geometry matches Liquid/Haze so - * chrome call sites never branch on the active backend. - */ -@Composable -internal fun FlatGlassSurface( - modifier: Modifier, - tint: Color, - cornerRadius: Dp, - border: BorderStroke?, - content: @Composable BoxScope.() -> Unit, -) { - val shape = RoundedCornerShape(cornerRadius) - Box( - modifier = - modifier - .clip(shape) - .background(tint, shape) - .let { if (border != null) it.border(border, shape) else it }, - content = content, - ) -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt index bc8fdb3..96d8bf7 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackdrop.kt @@ -13,13 +13,12 @@ import androidx.compose.ui.Modifier * Shared source/sampling state for glass chrome. * * AppShell wraps the screen body in [GlassBackdropSource]. GlassSurface backends - * consume [LocalGlassBackdropState] so Liquid/Haze sample the same layer behind + * consume [LocalGlassBackdropState] so Liquid sample the same layer behind * the dock/search chrome. */ @Stable class GlassBackdropState internal constructor( internal val liquidState: Any, - internal val hazeState: Any, ) val LocalGlassBackdropState = compositionLocalOf { null } @@ -27,11 +26,9 @@ val LocalGlassBackdropState = compositionLocalOf { null } @Composable fun rememberGlassBackdropState(): GlassBackdropState { val liquidState = rememberLiquidBackdropHandle() - val hazeState = rememberHazeBackdropHandle() - return remember(liquidState, hazeState) { + return remember(liquidState) { GlassBackdropState( - liquidState = liquidState, - hazeState = hazeState, + liquidState = liquidState ) } } @@ -46,8 +43,7 @@ fun GlassBackdropSource( Box( modifier = modifier - .liquidBackdropSource(state) - .hazeBackdropSource(state), + .liquidBackdropSource(state), content = content, ) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt deleted file mode 100644 index 29f6f72..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import androidx.compose.runtime.compositionLocalOf -import com.russhwolf.settings.Settings - -/** - * Three glass-effect backends per CONTEXT D-16. All three consume the same - * token API so chrome call sites never branch on the active backend. - */ -enum class GlassBackend { - Liquid, - Haze, - Flat, -} - -/** - * Composition root sets this to the resolved backend for the running build. - * Consumers outside a provider fail safe to the simplest visible substrate. - */ -val LocalGlassBackend = compositionLocalOf { GlassBackend.Flat } - -/** - * Debug-only runtime override key (D-17). Values: "liquid", "haze", "flat". - */ -const val DEBUG_GLASS_BACKEND_KEY: String = "debug.glass_backend" - -/** - * Pure backend resolver used by production code and common tests. - * - * Release builds return [default] without consulting settings, so production - * binaries do not carry a runtime backend switch. - */ -fun resolveGlassBackend( - settings: Settings, - isDebug: Boolean, - default: GlassBackend, -): GlassBackend { - if (!isDebug) return default - val raw = settings.getStringOrNull(DEBUG_GLASS_BACKEND_KEY) ?: return default - return when (raw.lowercase()) { - "liquid" -> GlassBackend.Liquid - "haze" -> GlassBackend.Haze - "flat" -> GlassBackend.Flat - else -> default - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt index 3ed3cf4..17714f5 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt @@ -9,11 +9,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.ui.theme.RecipeTheme -/** - * Single public entry point for glass-effect chrome. Dispatches to one backend - * through [LocalGlassBackend] and consumes the shared backdrop source when one - * is present above it. - */ @Composable fun GlassSurface( modifier: Modifier = Modifier, @@ -23,9 +18,5 @@ fun GlassSurface( content: @Composable BoxScope.() -> Unit, ) { val backdropState = LocalGlassBackdropState.current - when (LocalGlassBackend.current) { - GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content) - GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, backdropState, content) - GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content) - } + LiquidGlassSurface(modifier, tint, cornerRadius, border, backdropState, content) } diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt deleted file mode 100644 index fff23be..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/HazeGlassSurface.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.HazeStyle -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.rememberHazeState - -/** - * Haze 1.x backend per CONTEXT D-16. The actual 1.6.10 API takes a - * HazeStyle/block instead of a shape parameter, so shape is enforced by the - * surrounding clip while the effect consumes the shared [HazeState]. - */ -@Composable -internal fun HazeGlassSurface( - modifier: Modifier, - tint: Color, - cornerRadius: Dp, - border: BorderStroke?, - backdropState: GlassBackdropState?, - content: @Composable BoxScope.() -> Unit, -) { - val state = backdropState?.hazeState as? HazeState ?: rememberHazeState() - val shape = RoundedCornerShape(cornerRadius) - val style = - HazeStyle( - backgroundColor = tint.copy(alpha = 1f), - tint = HazeTint(tint), - blurRadius = 24.dp, - ) - Box( - modifier = - modifier - .clip(shape) - .hazeEffect(state, style) - .background(tint, shape) - .let { if (border != null) it.border(border, shape) else it }, - content = content, - ) -} - -@Composable -internal fun rememberHazeBackdropHandle(): Any = rememberHazeState() - -internal fun Modifier.hazeBackdropSource(state: GlassBackdropState): Modifier = hazeSource(state.hazeState as HazeState) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt deleted file mode 100644 index 9f6e9bb..0000000 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -/** - * Compile-time gate for the [resolveGlassBackend] runtime override path - * (CONTEXT D-17). - */ -expect val isDebugBuild: Boolean diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt index d0884a6..49497a6 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/search/SearchPill.kt @@ -111,10 +111,6 @@ fun SearchPill( } } -/** - * Internal helper — placeholder text rendered when the BasicTextField is empty. - * Plain text in [RecipeTheme.typography.body] tinted [RecipeTheme.colors.contentMuted]. - */ @Composable private fun PlaceholderText( text: String, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt index 6f98589..19643dc 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt @@ -61,7 +61,7 @@ import recipe.composeapp.generated.resources.search_dismiss_keyboard_a11y * Layout responsibilities: * - Background: full-screen [RecipeTheme.colors.background] under the safe area. * - Body: [RootNavHost] consumes the full screen, wrapped in [GlassBackdropSource] - * so Liquid/Haze chrome sample the screen body through [LocalGlassBackdropState]. + * so Liquid chrome samples the screen body through [LocalGlassBackdropState]. * - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill] * (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible). * Chrome consumes [WindowInsets.navigationBars] + [imePadding] explicitly per @@ -137,7 +137,7 @@ fun AppShell(modifier: Modifier = Modifier) { .background(RecipeTheme.colors.background), ) { // Body — RootNavHost fills the available space and is the shared source layer - // for Liquid/Haze chrome sampling via GlassBackdropSource (plan 02.1-03). + // for Liquid chrome sampling via GlassBackdropSource (plan 02.1-03). GlassBackdropSource(modifier = Modifier.fillMaxSize()) { RootNavHost( navController = navController, diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt index f6bb40c..1eb4326 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt @@ -5,9 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ReadOnlyComposable -import dev.ulfrx.recipe.ui.components.glass.GlassBackend -import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackend -import org.koin.compose.koinInject /** * Recipe theme entry point (CONTEXT D-14, D-15). @@ -35,7 +32,6 @@ public val LocalRecipeGlass: ProvidableCompositionLocal = public fun RecipeTheme(content: @Composable () -> Unit) { val dark = isSystemInDarkTheme() val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors - val glassBackend = koinInject() CompositionLocalProvider( LocalRecipeColors provides recipeColors, @@ -43,7 +39,6 @@ public fun RecipeTheme(content: @Composable () -> Unit) { LocalRecipeSpacing provides DefaultRecipeSpacing, LocalRecipeShapes provides DefaultRecipeShapes, LocalRecipeGlass provides DefaultRecipeGlass, - LocalGlassBackend provides glassBackend, content = content, ) } diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt deleted file mode 100644 index 28d5fd3..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ComposeAppCommonTest.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.ulfrx.recipe - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt deleted file mode 100644 index 345414b..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -package dev.ulfrx.recipe.auth - -import dev.lokksmith.client.request.flow.AuthFlow -import dev.lokksmith.client.request.flow.AuthFlowResultProvider -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNull - -class AuthSessionTest { - @Test - fun emptyStoreInitializesLoadingToUnauthenticated() { - runTest { - val session = newSession(store = FakeAuthStateStore()) - - assertIs(session.state.value) - - session.initialize() - - assertIs(session.state.value) - } - } - - @Test - fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() { - runTest { - val store = FakeAuthStateStore() - val oidcClient = - FakeOidcClient( - loginResult = - OidcResult.Success( - authStateJson = AUTH_STATE_JSON, - accessToken = ACCESS_TOKEN, - idToken = "id-token", - expiresAtEpochMillis = 123_456L, - ), - ) - val session = newSession(store = store, oidcClient = oidcClient) - - val result = session.login(NoopBrowser) - - assertEquals(AuthLoginResult.Success, result) - assertEquals(AUTH_STATE_JSON, store.value) - assertIs(session.state.value) - } - } - - @Test - fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() { - runTest { - val store = FakeAuthStateStore(value = "stored-auth-state-json") - val oidcClient = - FakeOidcClient( - refreshResult = - OidcResult.Success( - authStateJson = REFRESHED_AUTH_STATE_JSON, - accessToken = REFRESHED_ACCESS_TOKEN, - idToken = null, - expiresAtEpochMillis = 789_000L, - ), - ) - val session = newSession(store = store, oidcClient = oidcClient) - - session.initialize() - - assertEquals(emptyList(), oidcClient.loginCalls) - assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls) - assertEquals(REFRESHED_AUTH_STATE_JSON, store.value) - assertIs(session.state.value) - } - } - - @Test - fun refreshInvalidGrantClearsStoreAndEmitsUnauthenticatedWithoutUiError() { - runTest { - val store = FakeAuthStateStore(value = "stored-auth-state-json") - val oidcClient = - FakeOidcClient( - refreshResult = OidcResult.AuthError("invalid_grant"), - ) - val session = newSession(store = store, oidcClient = oidcClient) - - session.initialize() - - assertNull(store.value) - assertIs(session.state.value) - } - } - - @Test - fun refreshAuthErrorClearsStoreAndEmitsUnauthenticatedWithoutUiError() { - runTest { - val store = FakeAuthStateStore(value = "stored-auth-state-json") - val oidcClient = - FakeOidcClient( - refreshResult = OidcResult.AuthError("token endpoint rejected refresh"), - ) - val session = newSession(store = store, oidcClient = oidcClient) - - session.initialize() - - assertNull(store.value) - assertIs(session.state.value) - } - } - - @Test - fun logoutCallsEndSessionThenClearsStoreAndEmitsUnauthenticatedWhenLogoutSucceeds() { - runTest { - val store = FakeAuthStateStore(value = AUTH_STATE_JSON) - val oidcClient = FakeOidcClient() - val session = newSession(store = store, oidcClient = oidcClient) - - session.logout(NoopBrowser) - - assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls) - assertNull(store.value) - assertIs(session.state.value) - } - } - - @Test - fun logoutClearsStoreAndEmitsUnauthenticatedEvenWhenEndSessionThrows() { - runTest { - val store = FakeAuthStateStore(value = AUTH_STATE_JSON) - val oidcClient = FakeOidcClient(logoutThrows = true) - val session = newSession(store = store, oidcClient = oidcClient) - - session.logout(NoopBrowser) - - assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls) - assertNull(store.value) - assertIs(session.state.value) - } - } - - @Test - fun loginCancelledMapsToUiRenderableCancelledResult() { - runTest { - val store = FakeAuthStateStore() - val session = - newSession( - store = store, - oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled), - ) - - val result = session.login(NoopBrowser) - - assertEquals(AuthLoginResult.Cancelled, result) - assertNull(store.value) - assertIs(session.state.value) - } - } - - private fun newSession( - store: AuthStateStore = FakeAuthStateStore(), - oidcClient: OidcClientGateway = FakeOidcClient(), - ): AuthSession = - AuthSession( - oidcClient = oidcClient, - store = store, - ) - - private object NoopBrowser : AuthBrowser { - override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result = - AuthFlowResultProvider.Result.Undefined - } - - private class FakeAuthStateStore( - var value: String? = null, - ) : AuthStateStore { - override fun read(): String? = value - - override fun write(authStateJson: String) { - value = authStateJson - } - - override fun clear() { - value = null - } - } - - private class FakeOidcClient( - private val loginResult: OidcResult = OidcResult.AuthError("login not configured"), - private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"), - private val logoutThrows: Boolean = false, - ) : OidcClientGateway { - val loginCalls = mutableListOf() - val refreshCalls = mutableListOf() - val logoutCalls = mutableListOf() - - override suspend fun login(browser: AuthBrowser): OidcResult { - loginCalls += Unit - return loginResult - } - - override suspend fun refresh(authStateJson: String): OidcResult { - refreshCalls += authStateJson - return refreshResult - } - - override suspend fun logout( - authStateJson: String, - browser: AuthBrowser, - ) { - logoutCalls += authStateJson - if (logoutThrows) { - error("end-session failed") - } - } - } - - private companion object { - const val AUTH_STATE_JSON = """{"refresh_token":"initial"}""" - const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}""" - const val ACCESS_TOKEN = "access-token" - const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token" - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt deleted file mode 100644 index b5d6d03..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/SecureAuthStateStoreContractTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package dev.ulfrx.recipe.auth - -import com.russhwolf.settings.Settings -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -private class InMemorySettings : Settings { - private val map = mutableMapOf() - - override val keys: Set get() = map.keys - override val size: Int get() = map.size - - override fun clear() = map.clear() - - override fun remove(key: String) { - map.remove(key) - } - - override fun hasKey(key: String): Boolean = map.containsKey(key) - - override fun putInt( - key: String, - value: Int, - ) { - map[key] = value - } - - override fun getInt( - key: String, - defaultValue: Int, - ): Int = (map[key] as? Int) ?: defaultValue - - override fun getIntOrNull(key: String): Int? = map[key] as? Int - - override fun putLong( - key: String, - value: Long, - ) { - map[key] = value - } - - override fun getLong( - key: String, - defaultValue: Long, - ): Long = (map[key] as? Long) ?: defaultValue - - override fun getLongOrNull(key: String): Long? = map[key] as? Long - - override fun putString( - key: String, - value: String, - ) { - map[key] = value - } - - override fun getString( - key: String, - defaultValue: String, - ): String = (map[key] as? String) ?: defaultValue - - override fun getStringOrNull(key: String): String? = map[key] as? String - - override fun putFloat( - key: String, - value: Float, - ) { - map[key] = value - } - - override fun getFloat( - key: String, - defaultValue: Float, - ): Float = (map[key] as? Float) ?: defaultValue - - override fun getFloatOrNull(key: String): Float? = map[key] as? Float - - override fun putDouble( - key: String, - value: Double, - ) { - map[key] = value - } - - override fun getDouble( - key: String, - defaultValue: Double, - ): Double = (map[key] as? Double) ?: defaultValue - - override fun getDoubleOrNull(key: String): Double? = map[key] as? Double - - override fun putBoolean( - key: String, - value: Boolean, - ) { - map[key] = value - } - - override fun getBoolean( - key: String, - defaultValue: Boolean, - ): Boolean = (map[key] as? Boolean) ?: defaultValue - - override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean -} - -class SecureAuthStateStoreContractTest { - @Test - fun writeOverwritesPreviousValueAndReadReturnsLatest() { - val store = SecureAuthStateStore(InMemorySettings()) - - store.write("""{"refresh_token":"first"}""") - store.write("""{"refresh_token":"second"}""") - - assertEquals("""{"refresh_token":"second"}""", store.read()) - } - - @Test - fun clearRemovesStoredValue() { - val store = SecureAuthStateStore(InMemorySettings()) - - store.write("""{"refresh_token":"stored"}""") - store.clear() - - assertNull(store.read()) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt deleted file mode 100644 index 74067cc..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package dev.ulfrx.recipe.navigation - -import androidx.navigation.NavHostController -import androidx.navigation.navOptions -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * V-01 — UI-03 — `navigateToTab()` extension applies the four-flag multi-back-stack - * incantation: - * popUpTo(graph.findStartDestination().id) { saveState = true } - * launchSingleTop = true - * restoreState = true - * - * Strategy: the public NavHostController.navigateToTab call cannot be unit-tested - * without a live NavHostController (which requires a Compose composition runtime - * not available in pure commonTest). So we test the LAMBDA SHAPE that - * navigateToTab passes to navigate(...): we replicate the production lambda body - * against the official `navOptions { ... }` builder and assert the resulting - * NavOptions properties via the public `shouldXxx()` accessors. - */ -class NavigationTest { - @Test - fun navigateToTab_lambda_setsLaunchSingleTopAndRestoreState() { - val opts = - navOptions { - popUpTo(0) { saveState = true } - launchSingleTop = true - restoreState = true - } - - assertTrue(opts.shouldLaunchSingleTop(), "launchSingleTop must be true") - assertTrue(opts.shouldRestoreState(), "restoreState must be true") - assertTrue(opts.shouldPopUpToSaveState(), "popUpTo { saveState = true } must be set") - } - - @Test - fun navigateToTab_extension_isPublicAndDefinedOnNavHostController() { - // Compile-time + reflection-light assertion: the function exists with the - // expected signature. If it disappears or its signature drifts, the test - // file no longer compiles, which itself is a failed test. - val fn: (NavHostController, Any) -> Unit = { c, route -> c.navigateToTab(route) } - assertNotNull(fn) - } - - @Test - fun navigateToTab_lambda_setsAllFourFlagsTogether() { - // Belt-and-suspenders: a single test that the four flags fire together, - // not individually — UI-03 hard-coded contract. - val opts = - navOptions { - popUpTo(42) { saveState = true } - launchSingleTop = true - restoreState = true - } - assertEquals(true, opts.shouldLaunchSingleTop()) - assertEquals(true, opts.shouldRestoreState()) - assertEquals(true, opts.shouldPopUpToSaveState()) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt deleted file mode 100644 index 2db6632..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * V-03 - UI-04 - debug-build runtime override via multiplatform-settings - * honors "debug.glass_backend" values. Production builds ignore overrides. - */ -class GlassBackendOverrideTest { - @Test - fun resolveGlassBackend_debugBuildHonorsHazeOverride() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = true, - default = GlassBackend.Liquid, - ) - - assertEquals(GlassBackend.Haze, result) - } - - @Test - fun resolveGlassBackend_debugBuildHonorsFlatOverride() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "flat") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = true, - default = GlassBackend.Liquid, - ) - - assertEquals(GlassBackend.Flat, result) - } - - @Test - fun resolveGlassBackend_debugBuildHonorsLiquidOverride() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "liquid") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = true, - default = GlassBackend.Haze, - ) - - assertEquals(GlassBackend.Liquid, result) - } - - @Test - fun resolveGlassBackend_caseInsensitive() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "HAZE") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = true, - default = GlassBackend.Liquid, - ) - - assertEquals(GlassBackend.Haze, result) - } - - @Test - fun resolveGlassBackend_productionBuildIgnoresOverride() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "haze") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = false, - default = GlassBackend.Liquid, - ) - - assertEquals(GlassBackend.Liquid, result) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt deleted file mode 100644 index 0e510c4..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -import com.russhwolf.settings.Settings -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * V-02 - UI-04 - resolveGlassBackend(...) returns the compile-time default - * when no debug override is present. - */ -class GlassBackendTest { - @Test - fun resolveGlassBackend_iosDefault_returnsLiquid() { - val result = - resolveGlassBackend( - settings = MapSettings(), - isDebug = false, - default = GlassBackend.Liquid, - ) - assertEquals(GlassBackend.Liquid, result) - } - - @Test - fun resolveGlassBackend_emptySettings_returnsDefault() { - val result = - resolveGlassBackend( - settings = MapSettings(), - isDebug = true, - default = GlassBackend.Liquid, - ) - assertEquals(GlassBackend.Liquid, result) - } - - @Test - fun resolveGlassBackend_unknownOverride_returnsDefault() { - val settings = MapSettings() - settings.putString(DEBUG_GLASS_BACKEND_KEY, "neon-wave") - - val result = - resolveGlassBackend( - settings = settings, - isDebug = true, - default = GlassBackend.Liquid, - ) - - assertEquals(GlassBackend.Liquid, result) - } -} - -internal class MapSettings : Settings { - private val values = mutableMapOf() - - override val keys: Set - get() = values.keys - - override val size: Int - get() = values.size - - override fun clear() { - values.clear() - } - - override fun remove(key: String) { - values.remove(key) - } - - override fun hasKey(key: String): Boolean = key in values - - override fun putInt( - key: String, - value: Int, - ) { - values[key] = value - } - - override fun getInt( - key: String, - defaultValue: Int, - ): Int = getIntOrNull(key) ?: defaultValue - - override fun getIntOrNull(key: String): Int? = values[key] as? Int - - override fun putLong( - key: String, - value: Long, - ) { - values[key] = value - } - - override fun getLong( - key: String, - defaultValue: Long, - ): Long = getLongOrNull(key) ?: defaultValue - - override fun getLongOrNull(key: String): Long? = values[key] as? Long - - override fun putString( - key: String, - value: String, - ) { - values[key] = value - } - - override fun getString( - key: String, - defaultValue: String, - ): String = getStringOrNull(key) ?: defaultValue - - override fun getStringOrNull(key: String): String? = values[key] as? String - - override fun putFloat( - key: String, - value: Float, - ) { - values[key] = value - } - - override fun getFloat( - key: String, - defaultValue: Float, - ): Float = getFloatOrNull(key) ?: defaultValue - - override fun getFloatOrNull(key: String): Float? = values[key] as? Float - - override fun putDouble( - key: String, - value: Double, - ) { - values[key] = value - } - - override fun getDouble( - key: String, - defaultValue: Double, - ): Double = getDoubleOrNull(key) ?: defaultValue - - override fun getDoubleOrNull(key: String): Double? = values[key] as? Double - - override fun putBoolean( - key: String, - value: Boolean, - ) { - values[key] = value - } - - override fun getBoolean( - key: String, - defaultValue: Boolean, - ): Boolean = getBooleanOrNull(key) ?: defaultValue - - override fun getBooleanOrNull(key: String): Boolean? = values[key] as? Boolean -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt deleted file mode 100644 index a943663..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModelTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.auth - -import dev.lokksmith.client.request.flow.AuthFlow -import dev.lokksmith.client.request.flow.AuthFlowResultProvider -import dev.ulfrx.recipe.auth.AuthBrowser -import dev.ulfrx.recipe.auth.AuthSession -import dev.ulfrx.recipe.auth.AuthStateStore -import dev.ulfrx.recipe.auth.OidcClientGateway -import dev.ulfrx.recipe.auth.OidcResult -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.test.runTest -import recipe.composeapp.generated.resources.Res -import recipe.composeapp.generated.resources.auth_error_cancelled -import recipe.composeapp.generated.resources.auth_error_network -import recipe.composeapp.generated.resources.auth_error_unknown -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class LoginViewModelTest { - @Test - fun cancelledAuthFailureMapsToCancelledStringResource() = - runTest { - val session = newSession(loginResult = OidcResult.Cancelled) - val viewModel = LoginViewModel(session) - - viewModel.onSignInClick(NoopBrowser).join() - - assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) - assertEquals(false, viewModel.state.value.isLoading) - } - - @Test - fun networkAuthFailureMapsToNetworkStringResource() = - runTest { - val session = newSession(loginResult = OidcResult.NetworkError) - val viewModel = LoginViewModel(session) - - viewModel.onSignInClick(NoopBrowser).join() - - assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey) - assertEquals(false, viewModel.state.value.isLoading) - } - - @Test - fun unknownAuthFailureMapsToUnknownStringResource() = - runTest { - val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed")) - val viewModel = LoginViewModel(session) - - viewModel.onSignInClick(NoopBrowser).join() - - assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey) - assertEquals(false, viewModel.state.value.isLoading) - } - - @Test - fun successClearsErrorAndStopsLoading() = - runTest { - val session = - newSession( - loginResult = - OidcResult.Success( - authStateJson = "{}", - accessToken = "access", - idToken = null, - expiresAtEpochMillis = 0L, - ), - ) - val viewModel = LoginViewModel(session) - - viewModel.onSignInClick(NoopBrowser).join() - - assertNull(viewModel.state.value.errorKey) - assertEquals(false, viewModel.state.value.isLoading) - } - - @Test - fun startingNewSignInClearsPreviousErrorAndSetsLoading() = - runTest { - // Queue: first login resolves Cancelled to seed an inline error. - // Second login awaits a gate so we can synchronously observe the - // "loading=true, error=null" intermediate state contract from UI-SPEC. - val gate = CompletableDeferred() - val queue = mutableListOf(OidcResult.Cancelled) - val oidc = - object : OidcClientGateway { - override suspend fun login(browser: AuthBrowser): OidcResult = - if (queue.isNotEmpty()) queue.removeAt(0) else gate.await() - - override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used") - - override suspend fun logout( - authStateJson: String, - browser: AuthBrowser, - ) {} - } - val session = AuthSession(oidc, FakeAuthStateStore()) - val viewModel = LoginViewModel(session) - - // First attempt: error seeded. - viewModel.onSignInClick(NoopBrowser).join() - assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) - - // Second attempt: launching the job sets loading=true + clears error - // BEFORE suspending. onSignInClick() does that synchronously before - // returning the launched Job, so we can assert immediately. - val job = viewModel.onSignInClick(NoopBrowser) - assertTrue(viewModel.state.value.isLoading) - assertNull(viewModel.state.value.errorKey) - - // Release the gate; the second login also returns Cancelled. - gate.complete(OidcResult.Cancelled) - job.join() - - assertEquals(false, viewModel.state.value.isLoading) - assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey) - } - - private fun newSession( - loginResult: OidcResult, - store: AuthStateStore = FakeAuthStateStore(), - ): AuthSession = - AuthSession( - oidcClient = FakeOidcClient(loginResult = loginResult), - store = store, - ) - - private object NoopBrowser : AuthBrowser { - override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result = - AuthFlowResultProvider.Result.Undefined - } - - private class FakeAuthStateStore( - var value: String? = null, - ) : AuthStateStore { - override fun read(): String? = value - - override fun write(authStateJson: String) { - value = authStateJson - } - - override fun clear() { - value = null - } - } - - private class FakeOidcClient( - private val loginResult: OidcResult = OidcResult.AuthError("login not configured"), - private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"), - ) : OidcClientGateway { - override suspend fun login(browser: AuthBrowser): OidcResult = loginResult - - override suspend fun refresh(authStateJson: String): OidcResult = refreshResult - - override suspend fun logout( - authStateJson: String, - browser: AuthBrowser, - ) {} - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt deleted file mode 100644 index dcfff4b..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.pantry - -import dev.ulfrx.recipe.ui.screens.recipes.SearchState -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * V-07 — UI-10 — PantrySearchViewModel parity with RecipesSearchViewModel - * (open / close / clear semantics — CONTEXT D-07 / D-08). - */ -class PantrySearchViewModelTest { - @Test - fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() = - runTest { - val vm = PantrySearchViewModel() - vm.open() - vm.onQueryChange("mleko") - assertEquals(SearchState(isOpen = true, query = "mleko"), vm.state.value) - vm.close() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - } - - @Test - fun clear_resetsQueryButKeepsIsOpenTrue() = - runTest { - val vm = PantrySearchViewModel() - vm.open() - vm.onQueryChange("mleko") - vm.clear() - assertEquals(SearchState(isOpen = true, query = ""), vm.state.value) - } - - @Test - fun open_setsIsOpenTrueWithoutTouchingQuery() = - runTest { - val vm = PantrySearchViewModel() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - vm.open() - assertEquals(SearchState(isOpen = true, query = ""), vm.state.value) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt deleted file mode 100644 index 23495b5..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.recipes - -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * V-05 + V-06 — UI-10 — RecipesSearchViewModel state-machine semantics - * (RESEARCH § Pattern 4 + CONTEXT D-07 / D-08). - * - * V-05: open() → onQueryChange("foo") → close() leaves SearchState(isOpen=false, query=""). - * V-06: clear() resets only query, keeps isOpen=true. - */ -class RecipesSearchViewModelTest { - @Test - fun openThenQueryChangeThenClose_clearsQueryAndResetsIsOpen() = - runTest { - val vm = RecipesSearchViewModel() - vm.open() - vm.onQueryChange("foo") - assertEquals(SearchState(isOpen = true, query = "foo"), vm.state.value) - vm.close() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - } - - @Test - fun clear_resetsQueryButKeepsIsOpenTrue() = - runTest { - val vm = RecipesSearchViewModel() - vm.open() - vm.onQueryChange("foo") - vm.clear() - assertEquals(SearchState(isOpen = true, query = ""), vm.state.value) - } - - @Test - fun open_setsIsOpenTrueWithoutTouchingQuery() = - runTest { - val vm = RecipesSearchViewModel() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - vm.open() - assertEquals(SearchState(isOpen = true, query = ""), vm.state.value) - } - - @Test - fun onQueryChange_doesNotAffectIsOpen() = - runTest { - val vm = RecipesSearchViewModel() - vm.onQueryChange("foo") - assertEquals(SearchState(isOpen = false, query = "foo"), vm.state.value) - } - - @Test - fun closeFromAlreadyClosed_isIdempotent() = - runTest { - val vm = RecipesSearchViewModel() - vm.close() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - vm.close() - assertEquals(SearchState(isOpen = false, query = ""), vm.state.value) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt deleted file mode 100644 index 8d68f4f..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.ulfrx.recipe.ui.screens.shell - -import dev.ulfrx.recipe.RootRoute -import dev.ulfrx.recipe.auth.AuthState -import dev.ulfrx.recipe.resolveRootRoute -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * V-04 — UI-09 — App.kt's `Authenticated + currentUser != null` branch resolves - * to the AppShell route, not PostLoginPlaceholderScreen. - * - * Tested via the pure [resolveRootRoute] helper extracted in plan 02.1-08, so - * the routing semantics are deterministic without instrumenting a real Compose - * composition. (The CMP iOS Compose UI testing surface is too immature this - * phase for snapshot/UI tests on the actual `App()` composable — - * VALIDATION.md line 27.) - */ -class AppShellGateTest { - @Test - fun authenticatedWithUser_routesToShell_notPlaceholder() { - val route = - resolveRootRoute( - authState = AuthState.Authenticated, - hasCurrentUser = true, - ) - assertEquals(RootRoute.Shell, route) - } - - @Test - fun authenticatedWithoutUserYet_routesToSplash() { - // Two-layer gate per App.kt docstring: tokens present but /me has not - // returned yet → hold on splash, never show empty post-login. - val route = - resolveRootRoute( - authState = AuthState.Authenticated, - hasCurrentUser = false, - ) - assertEquals(RootRoute.Splash, route) - } - - @Test - fun unauthenticated_routesToLogin() { - val route = - resolveRootRoute( - authState = AuthState.Unauthenticated, - hasCurrentUser = false, - ) - assertEquals(RootRoute.Login, route) - } - - @Test - fun loadingAuth_routesToSplash() { - val route = - resolveRootRoute( - authState = AuthState.Loading, - hasCurrentUser = false, - ) - assertEquals(RootRoute.Splash, route) - } - - @Test - fun loadingAuthIgnoresHasCurrentUser() { - // Defensive: while Loading, we should always splash regardless of - // whether a stale currentUser is observable from a previous session. - val route = - resolveRootRoute( - authState = AuthState.Loading, - hasCurrentUser = true, - ) - assertEquals(RootRoute.Splash, route) - } -} diff --git a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/user/UserRepositoryTest.kt b/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/user/UserRepositoryTest.kt deleted file mode 100644 index 4d0487b..0000000 --- a/composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/user/UserRepositoryTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package dev.ulfrx.recipe.user - -import dev.lokksmith.client.request.flow.AuthFlow -import dev.lokksmith.client.request.flow.AuthFlowResultProvider -import dev.ulfrx.recipe.auth.AuthBrowser -import dev.ulfrx.recipe.auth.AuthSession -import dev.ulfrx.recipe.auth.AuthStateStore -import dev.ulfrx.recipe.auth.OidcClientGateway -import dev.ulfrx.recipe.auth.OidcResult -import dev.ulfrx.recipe.shared.dto.User -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class UserRepositoryTest { - @Test - fun fetchesUserWhenAuthFlipsToAuthenticated() = - runTest { - val session = newSession() - var fetchCount = 0 - val repository = - UserRepository( - authSession = session, - fetchUser = { - fetchCount++ - USER - }, - scope = TestScope(testScheduler), - ) - - session.login(NoopBrowser) - - val user = repository.currentUser.first { it != null } - assertEquals(USER, user) - assertEquals(1, fetchCount) - } - - @Test - fun clearsUserOnLogout() = - runTest { - val session = newSession() - val repository = - UserRepository( - authSession = session, - fetchUser = { USER }, - scope = TestScope(testScheduler), - ) - - session.login(NoopBrowser) - repository.currentUser.first { it != null } - - session.logout(NoopBrowser) - - val cleared = repository.currentUser.firstOrNull { it == null } - assertNull(cleared) - } - - @Test - fun networkFailureLeavesCurrentUserNullWithoutCrashing() = - runTest { - val session = newSession() - val repository = - UserRepository( - authSession = session, - fetchUser = { error("network down") }, - scope = TestScope(testScheduler), - ) - - session.login(NoopBrowser) - testScheduler.advanceUntilIdle() - - assertNull(repository.currentUser.value) - } - - private fun newSession(): AuthSession = - AuthSession( - oidcClient = FakeOidcClient(loginResult = SUCCESS), - store = FakeAuthStateStore(), - ) - - private object NoopBrowser : AuthBrowser { - override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result = - AuthFlowResultProvider.Result.Undefined - } - - private class FakeAuthStateStore( - var value: String? = null, - ) : AuthStateStore { - override fun read(): String? = value - - override fun write(authStateJson: String) { - value = authStateJson - } - - override fun clear() { - value = null - } - } - - private class FakeOidcClient( - private val loginResult: OidcResult, - ) : OidcClientGateway { - override suspend fun login(browser: AuthBrowser): OidcResult = loginResult - - override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used") - - override suspend fun logout( - authStateJson: String, - browser: AuthBrowser, - ) {} - } - - private companion object { - val SUCCESS = - OidcResult.Success( - authStateJson = "{}", - accessToken = "access", - idToken = null, - expiresAtEpochMillis = 0L, - ) - - val USER = - User( - id = "00000000-0000-0000-0000-000000000001", - sub = "authentik-sub", - email = "user@example.invalid", - displayName = "Recipe User", - ) - } -} diff --git a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt b/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt deleted file mode 100644 index 17cb354..0000000 --- a/composeApp/src/iosMain/kotlin/dev/ulfrx/recipe/ui/components/glass/IsDebugBuild.ios.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.ulfrx.recipe.ui.components.glass - -/** - * iOS actual: Kotlin/Native exposes whether the current binary was compiled - * for a debug configuration. - */ -@OptIn(kotlin.experimental.ExperimentalNativeApi::class) -actual val isDebugBuild: Boolean = kotlin.native.Platform.isDebugBinary diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fdd3315..0517279 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,6 @@ navigation-compose = "2.9.2" compose-unstyled = "1.49.9" compose-icons = "2.2.1" liquid = "1.1.1" -haze = "1.6.10" postgresql = "42.7.10" spotless = "8.4.0" testcontainers = "1.21.4" @@ -94,7 +93,6 @@ navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-co compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" } compose-icons-lucide = { module = "com.composables:icons-lucide-cmp", version.ref = "compose-icons" } liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" } -haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } # Phase 2 — Server: Exposed DSL + Hikari (D-26) exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }