Remove haze

This commit is contained in:
2026-05-10 12:01:54 +02:00
parent 568e793c44
commit 573b4562c2
27 changed files with 12 additions and 1384 deletions

View File

@@ -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()

View File

@@ -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<Settings> 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<GlassBackend> {
resolveGlassBackend(
settings = get<Settings>(),
isDebug = isDebugBuild,
default = GlassBackend.Liquid,
)
}
// Shell-level state machine.
viewModel<ShellViewModel>()

View File

@@ -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).

View File

@@ -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(
}
}
}

View File

@@ -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,
)
}

View File

@@ -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<GlassBackdropState?> { null }
@@ -27,11 +26,9 @@ val LocalGlassBackdropState = compositionLocalOf<GlassBackdropState?> { 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,
)
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<RecipeGlass> =
public fun RecipeTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val recipeColors = if (dark) DarkRecipeColors else LightRecipeColors
val glassBackend = koinInject<GlassBackend>()
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,
)
}

View File

@@ -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)
}
}

View File

@@ -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<AuthState.Loading>(session.state.value)
session.initialize()
assertIs<AuthState.Unauthenticated>(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<AuthState.Authenticated>(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<AuthState.Authenticated>(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<AuthState.Unauthenticated>(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<AuthState.Unauthenticated>(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<AuthState.Unauthenticated>(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<AuthState.Unauthenticated>(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<AuthState.Unauthenticated>(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<Unit>()
val refreshCalls = mutableListOf<String>()
val logoutCalls = mutableListOf<String>()
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"
}
}

View File

@@ -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<String, Any>()
override val keys: Set<String> 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())
}
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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<String, Any>()
override val keys: Set<String>
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
}

View File

@@ -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<OidcResult>()
val queue = mutableListOf<OidcResult>(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,
) {}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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",
)
}
}

View File

@@ -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