Implement main app navigation

This commit is contained in:
2026-05-08 14:03:26 +02:00
parent f7e866a08d
commit 794e27c554
90 changed files with 11725 additions and 187 deletions

View File

@@ -0,0 +1,529 @@
# Phase 2.1: App Shell, Navigation & Search Foundation — Pattern Map
**Mapped:** 2026-05-08
**Files analyzed:** ~28 new + 3 modified
**Analogs found:** 18 with strong analog / 13 greenfield (no in-repo analog yet — first occurrence of theme tokens, glass primitive, navigation graph)
---
## File Classification
### Modified files
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` | app entry / auth gate router | reactive state → composition switch | self (extend) | self-modify |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` | Koin app aggregator | DI wiring | self (extend `includes(...)` list) | self-modify |
| `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` | theme entry / CompositionLocal scaffold | reactive (system dark mode) → token provision | self (rewrite — currently a thin Material 3 wrapper) | self-rewrite (preserve `MaterialTheme(...)` call so legacy auth screens keep working) |
| `composeApp/src/commonMain/composeResources/values/strings.xml` | resource bundle | static lookup | self (extend with `shell_*`/`empty_*`/`search_*` keys) | self-modify |
| `gradle/libs.versions.toml` | version catalog | static config | self (extend) | self-modify |
| `composeApp/build.gradle.kts` | Gradle config | static config | self (extend `commonMain.dependencies`) | self-modify |
### New files — Navigation
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `navigation/Routes.kt` | route definitions | static `@Serializable` types | none in repo | greenfield — first nav graph |
| `navigation/BottomBarDestination.kt` | tab enum binding routes ↔ resources ↔ icons | static config | none in repo (`AuthState.kt` is the only enum-style sealed type) | greenfield |
| `navigation/RootNavHost.kt` | nested NavHost host | composition tree | none in repo | greenfield (RESEARCH.md § Pattern 1 + Code Example 1 lock the API) |
### New files — Theme tokens
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `ui/theme/RecipeColors.kt` | semantic color tokens (light/dark) | static data class + selection | `RecipeTheme.kt` (`LightColors`/`DarkColors` private vals) | partial-match (extend pattern from 2 colors → 9 semantic roles) |
| `ui/theme/RecipeTypography.kt` | typography tokens | static data class | none — no typography file exists yet | greenfield |
| `ui/theme/RecipeSpacing.kt` | spacing tokens | static data class | none | greenfield |
| `ui/theme/RecipeShapes.kt` | shape tokens (pill/circle radii) | static data class | none | greenfield |
| `ui/theme/RecipeGlass.kt` | glass-surface token defaults | static data class | none | greenfield |
### New files — Glass primitive
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `ui/components/glass/GlassSurface.kt` | layered chrome substrate primitive | composition (backend dispatch) | none in repo | greenfield (RESEARCH.md § Pattern 3 locks the API) |
| `ui/components/glass/GlassBackend.kt` | enum + `LocalGlassBackend` | static + CompositionLocal | none | greenfield |
| `ui/components/glass/LiquidGlassSurface.kt` | Liquid backend impl | composition | none — first Liquid use | greenfield |
| `ui/components/glass/HazeGlassSurface.kt` | Haze backend impl | composition | none — first Haze use | greenfield |
| `ui/components/glass/FlatGlassSurface.kt` | flat translucent fallback | composition | none | greenfield |
### New files — Shell + chrome composables
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `ui/screens/shell/AppShell.kt` | authenticated root composable | reactive (StateFlow → composition) | `LoginScreen.kt` + `App.kt` | partial-match (consumes `koinViewModel`, observes `StateFlow.collectAsStateWithLifecycle()`) |
| `ui/screens/shell/ShellViewModel.kt` | active-tab + search-open state machine | StateFlow + method-per-action | `LoginViewModel.kt`, `PostLoginViewModel.kt` | exact (same VM+StateFlow+method-per-action shape) |
| `ui/components/dock/DockBar.kt` | floating pill with 4 tabs + collapse-on-search | composition + `Modifier.animateContentSize` | none — first Compose Unstyled `TabGroup` consumer | greenfield |
| `ui/components/dock/FloatingSearchButton.kt` | adjacent floating circular icon button | composition | none — first Compose Unstyled `Button` consumer | greenfield |
| `ui/components/search/SearchPill.kt` | inline bottom search pill (renderless TextField) | composition + StateFlow input echo | `LoginScreen.kt` (TextField + button styling pattern, but Material 3) | role-match (gleaned from auth screen layout style only — input semantics are new) |
| `ui/components/empty/EmptyState.kt` | reusable empty-state composable | static composition | `LoginScreen.kt` Column-Center pattern | role-match (same Column / Arrangement.Center / horizontalAlignment skeleton) |
### New files — Tab screens & ViewModels
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `ui/screens/planner/PlannerScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact (same `Surface { Column { Text(stringResource(...)) } }` skeleton, but rebuilt on `RecipeTheme` instead of MaterialTheme) |
| `ui/screens/planner/PlannerViewModel.kt` | screen VM | StateFlow + method-per-action | `LoginViewModel.kt` | exact |
| `ui/screens/recipes/RecipesScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact |
| `ui/screens/recipes/RecipesViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact |
| `ui/screens/recipes/RecipesSearchViewModel.kt` | search state machine | StateFlow + method-per-action | `LoginViewModel.kt` | exact (shape mirrors; semantics from RESEARCH.md § Pattern 4) |
| `ui/screens/pantry/PantryScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact |
| `ui/screens/pantry/PantryViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact |
| `ui/screens/pantry/PantrySearchViewModel.kt` | search state machine | StateFlow | `LoginViewModel.kt` | exact |
| `ui/screens/shopping/ShoppingScreen.kt` | tab body screen | reactive | `PostLoginPlaceholderScreen.kt` | exact |
| `ui/screens/shopping/ShoppingViewModel.kt` | screen VM | StateFlow | `LoginViewModel.kt` | exact |
### New files — DI
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `di/ShellModule.kt` (or rolled into `AppModule`) | Koin module — VMs + glass backend factory | DI wiring | `auth/AuthModule.kt`, `user/UserModule.kt` | exact |
### New files — Tests
| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `commonTest/.../navigation/NavigationTest.kt` | nav extension unit test | pure function assertion | `LoginViewModelTest.kt` | role-match (same `kotlin.test` + `runTest` skeleton; subject under test is a NavOptions builder lambda) |
| `commonTest/.../ui/components/glass/GlassBackendTest.kt` | backend selection unit test | pure | `LoginViewModelTest.kt` | role-match |
| `commonTest/.../ui/components/glass/GlassBackendOverrideTest.kt` | debug-toggle test using `MapSettings` | pure | `LoginViewModelTest.kt` (fakes pattern) | role-match |
| `commonTest/.../ui/screens/shell/AppShellGateTest.kt` | App.kt routing assertion | reactive | `AuthSessionTest.kt` | exact (shape: `runTest` + state-flow observation + assert branches) |
| `commonTest/.../ui/screens/recipes/RecipesSearchViewModelTest.kt` | search VM unit test | StateFlow assertion | `LoginViewModelTest.kt` | exact |
| `commonTest/.../ui/screens/pantry/PantrySearchViewModelTest.kt` | search VM unit test | StateFlow assertion | `LoginViewModelTest.kt` | exact |
---
## Pattern Assignments
### `App.kt` (modified — auth gate router)
**Analog:** self — current `App.kt:43-58` has the `when (authState)` switch and the `Authenticated + currentUser` two-layer gate.
**Pattern to preserve** (`App.kt:43-58`):
```kotlin
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
AuthState.Authenticated -> {
val user = currentUser
if (user == null) {
SplashScreen()
} else {
PostLoginPlaceholderScreen(
user = user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}
```
**Modification:** replace the `PostLoginPlaceholderScreen(...)` call with `AppShell()` (which internally hosts `RootNavHost` and consumes its own `koinViewModel<ShellViewModel>()`). The `currentUser == null → SplashScreen()` arm stays. Do NOT change the `LaunchedEffect(authSession) { initialize() }` block (`App.kt:39-41`) — still load-bearing. Do NOT delete `PostLoginPlaceholderScreen` / `PostLoginViewModel` yet — RESEARCH.md § Open Question 3 + CONTEXT line 101 keep them as a logout-bridge possibility; if unused after wiring, retire them in a separate task with explicit confirmation.
---
### `RecipeTheme.kt` (rewritten — theme entry + CompositionLocal scaffold)
**Analog:** self — current shape (lines 18-35) is the structural template; the body is rewritten.
**Pattern to extend** (current `RecipeTheme.kt:18-35`):
```kotlin
private val LightColors = lightColorScheme(primary = Color(0xFF3B6939))
private val DarkColors = darkColorScheme(primary = Color(0xFFA2D597))
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
val colors = if (isSystemInDarkTheme()) DarkColors else LightColors
MaterialTheme(colorScheme = colors, content = content)
}
```
**New shape** (RESEARCH.md § Pattern 3 + UI-SPEC § Color/Typography/Spacing/Glass):
- Keep `MaterialTheme(colorScheme = ..., content = ...)` wrapping the inner block so legacy auth screens (`LoginScreen.kt:46`, `LoginScreen.kt:59``MaterialTheme.colorScheme.surface`, `MaterialTheme.typography.displaySmall`) keep resolving (Open Question 3, recommended resolution).
- Inside the `MaterialTheme { ... }`, wrap a `CompositionLocalProvider(LocalRecipeColors provides ..., LocalRecipeTypography provides ..., LocalRecipeSpacing provides ..., LocalRecipeShapes provides ..., LocalRecipeGlass provides ..., LocalGlassBackend provides ...) { content() }`.
- Public read site: `RecipeTheme.colors`, `RecipeTheme.typography`, `RecipeTheme.spacing`, `RecipeTheme.shapes`, `RecipeTheme.glass` — implement as a `companion object`-style `object RecipeTheme { val colors: RecipeColors @Composable @ReadOnlyComposable get() = LocalRecipeColors.current ... }` per the standard MaterialTheme idiom.
**Color values:** UI-SPEC § Color (lines 84-92) — verbatim hex. No mockup port.
---
### `ui/screens/shell/ShellViewModel.kt` (new — VM analog: `LoginViewModel`)
**Analog:** `ui/screens/auth/LoginViewModel.kt:37-55`
**State + method-per-action pattern** (`LoginViewModel.kt:37-55`):
```kotlin
class LoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(browser: AuthBrowser): Job {
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login(browser)
_state.value = LoginScreenState(isLoading = false, errorKey = result.toErrorKeyOrNull())
}
}
}
```
**Apply to `ShellViewModel`:**
- `data class ShellState(val activeTab: BottomBarDestination, val searchOpen: Boolean = false, val query: String = "")` — single source of truth.
- `private val _state = MutableStateFlow(ShellState(activeTab = BottomBarDestination.Planner))`; expose `state: StateFlow<ShellState> = _state.asStateFlow()`.
- Method-per-action: `fun openSearch()`, `fun closeSearch()` (D-08: clears query), `fun onQueryChange(q: String)`, `fun clearQuery()`, `fun onTabChanged(dest: BottomBarDestination)`.
- No `viewModelScope.launch` needed — pure synchronous state updates (no I/O this phase).
**Same pattern for** `PlannerViewModel`, `RecipesViewModel`, `PantryViewModel`, `ShoppingViewModel`, `RecipesSearchViewModel`, `PantrySearchViewModel`. The two `*SearchViewModel`s use `data class SearchState(val isOpen: Boolean = false, val query: String = "")` per RESEARCH.md § Pattern 4 (lines 395-405). Phase 5 extension hook: leave a nullable `searchSource: SearchSource? = null` constructor param — RESEARCH.md line 410.
---
### `ui/screens/shell/AppShell.kt` (new — composable analog: `LoginScreen`)
**Analog:** `ui/screens/auth/LoginScreen.kt:39-93` for the shape (Composable observing a VM + `collectAsStateWithLifecycle`); the actual layout follows RESEARCH.md § Code Example 2 (lines 514-565).
**ViewModel observation pattern** (`LoginScreen.kt:39-42`):
```kotlin
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
// ...
}
```
**Apply to `AppShell`:**
- Take no params (it lives behind the auth gate). Inside: `val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle()`.
- Acquire `navController = rememberNavController()`; render `RootNavHost(navController)` as the body.
- Bottom chrome is an `Align(BottomCenter)` overlay column: `if (ui.searchOpen && activeTab.hasSearch) SearchPill(...); DockBar(active=activeTab, collapsed=ui.searchOpen, ...)`.
- `FloatingSearchButton` aligned `BottomEnd`, visible only when `!ui.searchOpen && activeTab.hasSearch`.
**Inset handling** (avoid Pitfall F, RESEARCH.md lines 471-473): `Modifier.windowInsetsPadding(WindowInsets.navigationBars)` on the chrome column; screen bodies use `WindowInsets.statusBars` for top inset only. Do NOT use `safeContentPadding()` on AppShell — that's `LoginScreen.kt:52`'s pattern, but only because `LoginScreen` has no chrome overlay. AppShell has chrome, so it must consume insets explicitly.
---
### `ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt` (new — analog: `PostLoginPlaceholderScreen`)
**Analog:** `ui/screens/auth/PostLoginPlaceholderScreen.kt:32-62`
**Skeleton to mirror** (`PostLoginPlaceholderScreen.kt:38-61`):
```kotlin
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_welcome_format, user.displayName),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
// ...
}
}
```
**Adapt for tab screens:**
- Replace `Surface(... color = MaterialTheme.colorScheme.surface)` with `Box(Modifier.fillMaxSize().background(RecipeTheme.colors.background))`. UI-SPEC line 184: tab body background is `RecipeColors.background`, NOT a Material `Surface`. Also, do NOT import `androidx.compose.material3.*` in new screen code (CLAUDE.md / UI-SPEC line 31 / RESEARCH.md anti-pattern at line 419).
- Replace `MaterialTheme.typography.headlineSmall` with `RecipeTheme.typography.title` for the inline tab title (UI-SPEC line 64).
- Replace hardcoded `padding(horizontal = 16.dp)` with `padding(horizontal = RecipeTheme.spacing.lg)` (UI-SPEC § Spacing).
- The body region: inline title at top with `RecipeTheme.spacing.xl` top inset, then `EmptyState(icon = ..., title = stringResource(Res.string.empty_<tab>_title), subtitle = stringResource(Res.string.empty_<tab>_subtitle))` centered.
- Each `*Screen(vm: *ViewModel)` takes its VM as a parameter so the composable is testable / previewable in isolation; the call site in `RootNavHost`'s `composable<*Home>` block does the `koinViewModel(viewModelStoreOwner = parentEntry)` retrieval (RESEARCH.md § Pattern 2, lines 351-357).
---
### `ui/components/empty/EmptyState.kt` (new — analog: `LoginScreen` column skeleton)
**Analog:** `ui/screens/auth/LoginScreen.kt:48-92` for the centered Column pattern; full target shape locked by RESEARCH.md § Code Example 3 (lines 571-605) and UI-SPEC line 183.
**Centered Column pattern from analog** (`LoginScreen.kt:48-56`):
```kotlin
Column(
modifier = Modifier
.fillMaxSize()
.safeContentPadding()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { /* ... */ }
```
**Apply to `EmptyState`:**
- Signature locked by D-13 / UI-SPEC line 183: `EmptyState(icon: ImageVector, title: String, subtitle: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null)`.
- Replace `safeContentPadding()` with explicit horizontal `RecipeTheme.spacing.xl` (UI-SPEC line 183 sets the body inset and screen-level safe-area inset is owned by the screen, not the empty-state).
- Tint: `Icon(... tint = RecipeTheme.colors.contentMuted, modifier = Modifier.size(48.dp))` — UI-SPEC line 183.
- Spacing rhythm: icon → `Spacer(Modifier.height(RecipeTheme.spacing.sm))` → headline (`RecipeTheme.typography.display`, color `RecipeTheme.colors.content`) → `Spacer(... .lg)` → subline (`RecipeTheme.typography.body`, color `RecipeTheme.colors.contentMuted`) → if `action != null`, `Spacer(... .xl)` then `action()`.
- Wrap the Column in `Modifier.semantics(mergeDescendants = true) {}` (UI-SPEC line 226; one-announce VoiceOver reading).
---
### `di/ShellModule.kt` (new — analog: `auth/AuthModule.kt`)
**Analog:** `auth/AuthModule.kt:9-25` and `user/UserModule.kt:10-23`.
**Module + viewModel registration pattern** (`AuthModule.kt:9-25`):
```kotlin
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
single<OidcClient> { OidcClient(get()) }
single<AuthSession> { AuthSession(oidcClient = get<OidcClient>(), store = get<SecureAuthStateStore>()) }
single<HttpClient> { AuthHttpClient.create(get()) }
viewModel<LoginViewModel>()
viewModel<PostLoginViewModel>()
}
```
**Apply to `shellModule`:**
- `viewModel<ShellViewModel>()`, `viewModel<PlannerViewModel>()`, `viewModel<RecipesViewModel>()`, `viewModel<RecipesSearchViewModel>()`, `viewModel<PantryViewModel>()`, `viewModel<PantrySearchViewModel>()`, `viewModel<ShoppingViewModel>()`.
- A `single<GlassBackend> { resolveGlassBackend(get<Settings>()) }` if the debug-toggle resolution is materialized at module level. Settings comes from `multiplatform-settings` (RESEARCH.md A5 — already wired from Phase 2).
- Same imports: `import org.koin.dsl.module`, `import org.koin.plugin.module.dsl.viewModel`.
---
### `di/AppModule.kt` (modified)
**Analog:** self.
**Pattern to extend** (`AppModule.kt:8-11`):
```kotlin
val appModule =
module {
includes(authModule, userModule)
}
```
**Modification:** add `shellModule` to the `includes(...)` list. One-line change. The comment on line 7 should be updated to reflect Phase 2.1 addition.
---
### `composeResources/values/strings.xml` (modified)
**Analog:** self — current file has the `auth_*` keys.
**Pattern to extend** (full current file shown above — `strings.xml:7-15`). Add the `shell_*`, `empty_*`, `search_*` resource keys per UI-SPEC § Copywriting Contract (lines 121-158) and RESEARCH.md § Code Example 4 (lines 615-637). Preserve all existing `auth_*` keys; only append.
---
### `ui/components/dock/DockBar.kt` (new — greenfield)
**Analog:** none in repo. **Stylistic reference:** `LoginScreen.kt:62-80` (button structure with conditional content via `if (state.isLoading)`).
**Key API contract** (locked by UI-SPEC line 180 + CONTEXT D-01 through D-05):
- Signature: `DockBar(destinations: List<BottomBarDestination>, active: BottomBarDestination, collapsed: Boolean, onTabSelect: (BottomBarDestination) -> Unit, onCollapsedTap: () -> Unit, modifier: Modifier = Modifier)`.
- Substrate: `GlassSurface(cornerRadius = 28.dp, ...)` for expanded; `GlassSurface(cornerRadius = 22.dp, ...)` for collapsed (UI-SPEC line 253).
- Built on Compose Unstyled `TabGroup` primitive (UI-SPEC line 180; RESEARCH.md line 137 — `com.composables:composeunstyled:1.49.9`).
- Animation: `Modifier.animateContentSize()` for expanded↔collapsed size + `AnimatedContent` for icon/label visibility crossfade. 250ms `FastOutSlowInEasing` per UI-SPEC line 198. Single coordinated motion (D-05).
- Each cell: `Modifier.semantics { role = Role.Tab; selected = isActive }` (UI-SPEC line 220).
- Touch target ≥ 44dp on iOS / 48dp on Android (UI-SPEC line 52, 224).
---
### `ui/components/dock/FloatingSearchButton.kt` (new — greenfield)
**Analog:** none. UI-SPEC line 181.
- Signature: `FloatingSearchButton(onClick: () -> Unit, modifier: Modifier = Modifier)`.
- Built on Compose Unstyled `Button`, wrapping a `GlassSurface(cornerRadius = 22.dp)` (44dp full-circle).
- Icon: `Icons.Outlined.Search`, tinted `RecipeTheme.colors.content`.
- `contentDescription = stringResource(Res.string.search_open_a11y)`.
---
### `ui/components/search/SearchPill.kt` (new — greenfield)
**Analog:** stylistic only — nothing equivalent in repo. UI-SPEC line 182.
- Signature: `SearchPill(query: String, onQueryChange: (String) -> Unit, onClear: () -> Unit, onClose: () -> Unit, placeholder: String, modifier: Modifier = Modifier)`.
- Built on Compose Unstyled `TextField` renderless primitive — apply local styling, do NOT roll a Material `OutlinedTextField`.
- 44dp height, 22dp corner radius, `surfaceGlass` substrate (UI-SPEC line 253).
- Leading search icon, trailing clear button visible only when `query.isNotEmpty()`.
- `imePadding()` so the pill rides above the soft keyboard (UI-SPEC line 271; Pitfall F).
---
### `ui/components/glass/GlassSurface.kt` + backends (new — all greenfield)
**Analog:** none. RESEARCH.md § Pattern 3 (lines 367-388) is the API lock.
```kotlin
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
content: @Composable BoxScope.() -> Unit,
)
```
- Backend selected via `LocalGlassBackend.current` (CompositionLocal set once at `RecipeTheme`/`AppShell` startup).
- Compile-time per target via `expect/actual` of an `expect val defaultGlassBackend: GlassBackend` in `commonMain` with `actual`s in `iosMain` (Liquid) and `androidMain` (Liquid). If targets emerge where Liquid does not compile, the `actual` returns `Haze`.
- Debug runtime override: `multiplatform-settings` key `"debug.glass_backend"` checked at `RecipeTheme` init, in DEBUG builds only (gate via an `expect val isDebugBuild: Boolean`). Production binaries compile out the override path.
- Liquid path uses `rememberLiquidState()` + `Modifier.liquefiable(state)` on the screen-body backdrop (set at `AppShell` level — Pitfall C, RESEARCH.md lines 454-458) and `Modifier.liquid(state)` on the chrome (`DockBar`, `SearchPill`, `FloatingSearchButton` interiors).
---
### `navigation/Routes.kt`, `BottomBarDestination.kt`, `RootNavHost.kt` (new — all greenfield)
**Analog:** none. RESEARCH.md § Pattern 1 (lines 304-339) and § Code Example 1 (lines 487-510) lock the shape verbatim.
Key contracts:
- `@Serializable data object PlannerGraph; @Serializable data object PlannerHome; ...` — type-safe routing.
- `enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector, val hasSearch: Boolean, val searchPlaceholder: StringResource?)`. The `hasSearch` flag drives D-06 (search visibility per tab).
- `NavHostController.navigateToTab(graphRoute: Any)` extension applies `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true`. This is the unit under test in `NavigationTest.kt`.
- Per-tab VM scoping: in each `composable<*Home>` block, `val parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)` (RESEARCH.md § Pattern 2). Set this pattern now even with a single screen per graph — Phase 5 inherits cleanly.
---
### Test files (new)
**Analog:** `commonTest/.../ui/screens/auth/LoginViewModelTest.kt:21-77` for VM tests; `commonTest/.../auth/AuthSessionTest.kt:11-29` for state-flow gate tests.
**Pattern from `LoginViewModelTest.kt`** (lines 22-32):
```kotlin
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)
}
}
```
**Apply to `RecipesSearchViewModelTest` / `PantrySearchViewModelTest`:**
- `runTest { ... }` block; no fakes needed (VMs are pure — no I/O).
- Cover: open() → `isOpen=true`; onQueryChange("foo") → `query="foo"`; close() → `isOpen=false, query=""` (D-08); clear() → `query="", isOpen=true` (UI-SPEC line 206 + CONTEXT D-08).
**Apply to `AppShellGateTest`** — mirror `AuthSessionTest.kt:13-23` shape (state-machine assertion via `runTest`). Drives `App()` indirectly by stubbing `AuthSession` + `UserRepository` via Koin test container, asserts `Authenticated + currentUser != null` resolves to AppShell rather than the placeholder. Plan to inject test doubles via Koin `startKoin { modules(...) }` per `Koin.kt:7-11` shape.
**Apply to `NavigationTest`** — assert the `navigateToTab(...)` extension's `NavOptionsBuilder` lambda flips the four flags. If `TestNavHostController` is unavailable in CMP commonTest, assert by capturing a fake builder. Mark this as an investigation point in Wave 0.
**Apply to `GlassBackendTest` / `GlassBackendOverrideTest`** — pure-function tests over the `resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend)` function. Use `MapSettings` (multiplatform-settings test impl) per RESEARCH.md line 731.
---
## Shared Patterns
### Externalized strings (UI-01, CLAUDE.md #9)
**Source:** `composeResources/values/strings.xml` + `recipe.composeapp.generated.resources.Res`.
**Apply to:** every new screen, every new component that displays user-facing text. Zero hardcoded literals.
**Reference call site** (`LoginScreen.kt:28-31, 58, 78`):
```kotlin
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.auth_app_name
// ...
Text(text = stringResource(Res.string.auth_app_name), ...)
```
**ViewModel-side resource handles** — when a VM needs to surface a string to the screen but stay locale-agnostic, return a `StringResource` (not a `String`). See `LoginViewModel.kt:13, 24, 57-63`:
```kotlin
import org.jetbrains.compose.resources.StringResource
// ...
data class LoginScreenState(val isLoading: Boolean = false, val errorKey: StringResource? = null)
```
This phase: search VM state holds the raw `query: String` (it's user input, not a localized message). The `placeholder` for the search pill is resolved via the per-tab `searchPlaceholder: StringResource` on `BottomBarDestination`.
### ViewModel + StateFlow + method-per-action (CLAUDE.md convention)
**Source:** `LoginViewModel.kt:37-55`, `PostLoginViewModel.kt:15-23`.
**Apply to:** `ShellViewModel`, `PlannerViewModel`, `RecipesViewModel`, `RecipesSearchViewModel`, `PantryViewModel`, `PantrySearchViewModel`, `ShoppingViewModel`.
Universal shape:
- `private val _state = MutableStateFlow(<TabState>())`
- `val state: StateFlow<TabState> = _state.asStateFlow()`
- Each action is a method on the VM that calls `_state.update { ... }` or `_state.value = ...`.
- No `LiveData`, no `mutableStateOf` for primary state — `StateFlow` only.
### Screen → VM observation
**Source:** `App.kt:33-34`, `LoginScreen.kt:40`.
**Pattern:**
```kotlin
import androidx.lifecycle.compose.collectAsStateWithLifecycle
// ...
val state by viewModel.state.collectAsStateWithLifecycle()
```
**Apply to:** every new screen and to `AppShell`. Use `collectAsStateWithLifecycle` not `collectAsState` so iOS/Android lifecycle-aware suspension works.
### Koin VM injection at composition
**Source:** `App.kt:46, 55`, `AuthModule.kt:23-24`.
**Pattern:**
- Module: `viewModel<*ViewModel>()`.
- Call site: `val vm: *ViewModel = koinViewModel<*ViewModel>()` for non-tab-scoped, OR `val parent = remember(entry) { navController.getBackStackEntry(*Graph) }; val vm: *ViewModel = koinViewModel(viewModelStoreOwner = parent)` for tab-graph-scoped (RESEARCH.md § Pattern 2 — set the scoping pattern from day one).
### iOS-safe inset handling
**Apply to:** `AppShell` (chrome insets), every screen body (top inset).
- Chrome bottom: `Modifier.windowInsetsPadding(WindowInsets.navigationBars)` (or `.union(WindowInsets.ime)` for the search pill).
- Body top: respect `WindowInsets.statusBars` via padding.
- Do NOT layer `safeContentPadding()` on both AppShell and screens — Pitfall F.
### Material 3 boundary
**Source:** UI-SPEC line 31; CLAUDE.md project decision; RESEARCH.md anti-pattern at line 419.
**Apply to:** every new file outside `ui/screens/auth/`. **No `androidx.compose.material3.*` imports** in new code. Tab screens replace `Surface(... color = MaterialTheme.colorScheme.surface)` with `Box(Modifier.background(RecipeTheme.colors.background))`. Replace `MaterialTheme.typography.*` with `RecipeTheme.typography.*`. Use Compose Unstyled primitives where a renderless analog exists.
The legacy auth screens (`LoginScreen.kt`, `PostLoginPlaceholderScreen.kt`, `SplashScreen.kt`) keep their Material 3 imports — explicit user discretion in CONTEXT line 52, default "leave auth screens as-is".
### Glass on chrome only
**Source:** CLAUDE.md non-negotiable #10; PITFALLS Pitfall 5/12.
**Apply to:** `GlassSurface` is consumed by `DockBar`, `FloatingSearchButton`, `SearchPill` exclusively. Tab body / EmptyState / future list rows render flat. Lint discipline per Pitfall E — any direct Liquid/Haze API import outside `ui/components/glass/` is a bug.
---
## No Analog Found (greenfield, lean on RESEARCH.md / UI-SPEC)
| File | Role | Why no analog | Locked by |
|------|------|---------------|-----------|
| `navigation/Routes.kt` + `RootNavHost.kt` + `BottomBarDestination.kt` | nav graph | First nav graph in repo (Phase 2 used a `when (authState)` switch in App.kt) | RESEARCH.md § Pattern 1, Code Example 1 |
| `ui/theme/RecipeColors.kt` (full token set) | semantic color scaffold | Current `RecipeTheme.kt` only has a 2-color seed | UI-SPEC § Color (lines 84-92) |
| `ui/theme/RecipeTypography.kt` | typography scale | None exists | UI-SPEC § Typography (lines 60-72) |
| `ui/theme/RecipeSpacing.kt` | spacing tokens | None exists | UI-SPEC § Spacing (lines 36-54) |
| `ui/theme/RecipeShapes.kt` | shape tokens | None exists | UI-SPEC § Glass (line 253) |
| `ui/theme/RecipeGlass.kt` | glass token defaults | None exists | UI-SPEC § Glass (lines 248-256) |
| `ui/components/glass/GlassSurface.kt` + 3 backends | layered glass primitive | First Liquid/Haze use in repo | RESEARCH.md § Pattern 3, Liquid README |
| `ui/components/dock/DockBar.kt` | floating tab pill with collapse animation | First Compose Unstyled `TabGroup` consumer; first animated chrome | UI-SPEC line 180; RESEARCH.md § Code Example 2 |
| `ui/components/dock/FloatingSearchButton.kt` | floating circular icon button | First Compose Unstyled `Button` consumer | UI-SPEC line 181 |
| `ui/components/search/SearchPill.kt` | inline bottom search input | First Compose Unstyled `TextField` consumer; first IME-aware chrome | UI-SPEC line 182; RESEARCH.md § Pattern 4 |
| `ui/components/empty/EmptyState.kt` | reusable empty-state | First component in `ui/components/` | UI-SPEC line 183; RESEARCH.md § Code Example 3 |
For these files, the planner should:
1. Reference the locked API in UI-SPEC (signatures, dimensions, tokens).
2. Reference the implementation patterns in RESEARCH.md (code examples + library APIs).
3. Apply the **shared patterns** above (strings externalized, RecipeTheme tokens, no Material 3, glass-only-on-chrome) verbatim — these are not greenfield even when the file is.
---
## Metadata
**Analog search scope:** `composeApp/src/{commonMain,commonTest,iosMain,androidMain}/kotlin/dev/ulfrx/recipe/**` — full client tree.
**Files scanned:** ~45 source files (entire current `composeApp` Kotlin tree post-Phase-2).
**Strongest analogs identified:** `LoginViewModel.kt`, `PostLoginPlaceholderScreen.kt`, `AuthModule.kt`, `RecipeTheme.kt` (current), `LoginViewModelTest.kt`, `App.kt`.
**Pattern extraction date:** 2026-05-08