Files
recipe/.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-08-PLAN.md

42 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02.1 08 execute 5
02.1-05
02.1-06
02.1-07
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt
true
UI-09
kotlin
koin
di
app-entry
navigation
glass
expect-actual
integration
multiplatform-settings
truths artifacts key_links
shellModule registers all 4 tab VMs (PlannerViewModel, RecipesViewModel, PantryViewModel, ShoppingViewModel), both Search VMs (RecipesSearchViewModel, PantrySearchViewModel), ShellViewModel, and a single<GlassBackend> { resolveGlassBackend(get<Settings>(), isDebugBuild, default) } provider
AppModule.includes(...) gains shellModule alongside authModule + userModule
App.kt's Authenticated + currentUser != null branch resolves to AppShell() instead of PostLoginPlaceholderScreen(...)
App.kt preserves the LaunchedEffect(authSession) { initialize() } block and the currentUser == null → SplashScreen() arm
PostLoginPlaceholderScreen + PostLoginViewModel are NOT deleted (logout-bridge possibility per CONTEXT line 101 / RESEARCH § Open Questions Q3)
RecipeTheme.kt provides LocalGlassBackend via CompositionLocalProvider so AppShell + chrome composables resolve the backend
RootNavHost's TabHomePlaceholder stubs (from plan 02.1-04) are replaced with the real Tab*Screen calls using koinViewModel(viewModelStoreOwner = parent) per RESEARCH § Pattern 2
V-04 anchor: AppShellGateTest replaces its @Ignore stub with a real test asserting that App's Authenticated+user routing branches to the AppShell branch (or extracted RootRouter pure function)
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt Koin shellModule — 7 VMs + GlassBackend single val shellModule
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt appModule extended to include shellModule shellModule
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt App() composable routing Authenticated+user to AppShell() AppShell()
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt RootNavHost wired to call PlannerScreen / RecipesScreen / PantryScreen / ShoppingScreen with VM scoping PlannerScreen
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt RecipeTheme provides LocalGlassBackend value resolved at startup LocalGlassBackend provides
path provides
composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt V-04 anchor — real assertion that Authenticated+user routes to AppShell branch
from to via pattern
App.kt ui/screens/shell/AppShell.kt Authenticated branch invokes AppShell() instead of PostLoginPlaceholderScreen(...) AppShell()
from to via pattern
di/AppModule.kt di/ShellModule.kt includes(authModule, userModule, shellModule) shellModule
from to via pattern
di/ShellModule.kt ui/components/glass/GlassBackend.kt single<GlassBackend> { resolveGlassBackend(get<Settings>(), isDebugBuild, default) } resolveGlassBackend
from to via pattern
ui/theme/RecipeTheme.kt ui/components/glass/GlassBackend.kt CompositionLocalProvider(LocalGlassBackend provides koinInject<GlassBackend>()) LocalGlassBackend
from to via pattern
navigation/RootNavHost.kt ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt composable<*Home>{ koinViewModel(viewModelStoreOwner = parent) → Tab*Screen(viewModel = vm) } PlannerScreen(viewModel =
Final integration — wire the seven shell ViewModels and the GlassBackend resolver into a Koin `shellModule`; extend `appModule.includes(...)` to pull in `shellModule`; provide `LocalGlassBackend` in `RecipeTheme` so all chrome consuming `GlassSurface` resolves the right backend; replace the four `TabHomePlaceholder` stubs in `RootNavHost.kt` (from plan 02.1-04) with calls into the real `PlannerScreen` / `RecipesScreen` / `PantryScreen` / `ShoppingScreen` (from plan 02.1-07) using `koinViewModel(viewModelStoreOwner = parent)` per RESEARCH § Pattern 2; and finally swap the `Authenticated + currentUser != null` branch in `App.kt` from `PostLoginPlaceholderScreen(...)` to `AppShell()`.

Replace the @Ignore'd Wave-0 stub in AppShellGateTest.kt (V-04) with a real assertion. The cleanest test path: extract the routing logic in App.kt into a pure RootRouter enum (Splash / Login / Shell) computed from (authState, currentUser) and assert the enum value directly. The App() composable becomes a thin wrapper that switches on the enum. This keeps the test deterministic without instrumenting Compose composition.

PostLoginPlaceholderScreen.kt and PostLoginViewModel.kt are NOT deleted — RESEARCH § Open Questions Q3 (now RESOLVED) and CONTEXT line 101 keep them as a logout-bridge possibility. They are simply no longer reachable from the auth-gate flow this phase. A future phase may delete them or repurpose them.

Per CONTEXT line 52, the auth screens (LoginScreen, PostLoginPlaceholderScreen, SplashScreen) keep their Material 3 imports as legacy. Plan 02.1-02 preserved MaterialTheme(colorScheme = ...) wrapping in RecipeTheme so those screens keep working.

Purpose: turn the shell from "exists in the codebase" to "actually rendered after sign-in". UI-09 final closure: the Authenticated user lands in the real shell, not the placeholder. Output: 1 new file (ShellModule.kt) + 5 modified files; 1 test un-ignored covering V-04.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt @composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt @composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt After Wave 4 (plan 02.1-05) and its prerequisites (02.1-06, 02.1-07) land, the following symbols are available:

From plan 02.1-05:

  • dev.ulfrx.recipe.ui.screens.shell.AppShell — composable taking no required params
  • dev.ulfrx.recipe.ui.screens.shell.ShellViewModel

From plan 02.1-06:

  • dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel(searchSource: SearchSource? = null)
  • dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel(searchSource: SearchSource? = null)

From plan 02.1-07:

  • dev.ulfrx.recipe.ui.screens.planner.{PlannerScreen, PlannerViewModel}
  • dev.ulfrx.recipe.ui.screens.recipes.{RecipesScreen, RecipesViewModel}
  • dev.ulfrx.recipe.ui.screens.pantry.{PantryScreen, PantryViewModel}
  • dev.ulfrx.recipe.ui.screens.shopping.{ShoppingScreen, ShoppingViewModel}

From plan 02.1-03:

  • dev.ulfrx.recipe.ui.components.glass.{GlassBackend, LocalGlassBackend, resolveGlassBackend, isDebugBuild, DEBUG_GLASS_BACKEND_KEY}

From plan 02.1-04:

  • dev.ulfrx.recipe.navigation.{PlannerGraph, PlannerHome, RecipesGraph, RecipesHome, PantryGraph, PantryHome, ShoppingGraph, ShoppingHome}

Existing analog (auth/AuthModule.kt:9-25) — Koin module shape:

val authModule = module {
    single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
    // ...
    viewModel<LoginViewModel>()
    viewModel<PostLoginViewModel>()
}

Existing AppModule (di/AppModule.kt):

val appModule = module {
    includes(authModule, userModule)
}

com.russhwolf:multiplatform-settings:1.3.0 provides Settings interface — already on commonMain via Phase 2 (used by SecureAuthStateStore) and registered in Koin.

Current App.kt structure (App.kt:43-58):

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

The modification: replace the PostLoginPlaceholderScreen(...) call (lines 53-56) with AppShell(). The currentUser == null → SplashScreen() arm stays. The LaunchedEffect(authSession) { initialize() } block (lines 39-41) stays untouched.

Task 1: Create ShellModule.kt + extend AppModule.kt + provide LocalGlassBackend in RecipeTheme composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt, composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthModule.kt — analog Koin module shape (lines 9-25) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt — current state (preserve includes; just append shellModule) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — current state from plan 02.1-02 (must preserve MaterialTheme wrapper for legacy auth screens — RESEARCH § Open Questions Q3 RESOLVED) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackend.kt — for resolveGlassBackend signature - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § di/ShellModule (lines 268-289) + § di/AppModule (lines 293-304) - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-17 — debug runtime override mechanism Step 1 — create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt`:
```kotlin
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
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.shell.ShellViewModel
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 line 410.
 *  - 1 ShellViewModel — active-tab + search-open state machine.
 *  - 1 GlassBackend single — resolved at composition root from
 *    [resolveGlassBackend] (CONTEXT D-16 / D-17). The default backend chosen here
 *    is [GlassBackend.Liquid] — the iOS+Android primary path; if Liquid fails to
 *    compile for a future target, the per-target source-set actual will pick
 *    [GlassBackend.Haze] or [GlassBackend.Flat] before this resolve runs.
 */
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>()

        // Tab ViewModels — empty-state-only this phase; feature phases extend them.
        viewModel<PlannerViewModel>()
        viewModel<RecipesViewModel>()
        viewModel<PantryViewModel>()
        viewModel<ShoppingViewModel>()

        // Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject
        // their respective SearchSource implementations.
        viewModel<RecipesSearchViewModel>()
        viewModel<PantrySearchViewModel>()
    }
```

Note on `Settings` provider: `Settings` is already registered in Koin via the
multiplatform-settings wiring from Phase 1 / Phase 2 (used by `SecureAuthStateStore`).
If `get<Settings>()` does not resolve (Koin can't find a Settings binding), then
multiplatform-settings was registered scoped or under a different type. In that
case, inspect `auth/AuthModule.kt` and the platform-specific Koin modules; either
promote the Settings binding to a single<Settings> in commonMain shellModule, or
reuse whatever scope SecureAuthStateStore used.

Step 2 — modify `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt`:

Replace the existing `appModule` declaration with:
```kotlin
package dev.ulfrx.recipe.di

import dev.ulfrx.recipe.auth.authModule
import dev.ulfrx.recipe.user.userModule
import org.koin.dsl.module

// Phase 2 added authModule + userModule. Phase 2.1 adds shellModule (UI-03/04/09/10).
// Phase 4 will add syncModule; Phase 5 will add catalogModule; etc.
val appModule =
    module {
        includes(authModule, userModule, shellModule)
    }
```

Step 3 — modify `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` to provide `LocalGlassBackend` to all descendants.

Plan 02.1-02 produced a `RecipeTheme` composable that wraps `MaterialTheme(...)` and
provides `LocalRecipeColors` / `LocalRecipeTypography` / etc. via
`CompositionLocalProvider`. THIS plan adds one more local: `LocalGlassBackend`,
resolved via `koinInject<GlassBackend>()` at startup.

Read the current RecipeTheme.kt (post plan 02.1-02). Locate the `CompositionLocalProvider(...)` block.
Add `LocalGlassBackend provides koinInject<GlassBackend>()` to the `provides` list.

Required additional imports in RecipeTheme.kt:
```kotlin
import dev.ulfrx.recipe.ui.components.glass.GlassBackend
import dev.ulfrx.recipe.ui.components.glass.LocalGlassBackend
import org.koin.compose.koinInject
```

Conceptual edit (for guidance — actual line numbers depend on plan 02.1-02's output):

Before:
```kotlin
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
    val colors = if (isSystemInDarkTheme()) DarkRecipeColors else LightRecipeColors
    MaterialTheme(colorScheme = colors.toMaterialColorScheme()) {
        CompositionLocalProvider(
            LocalRecipeColors provides colors,
            LocalRecipeTypography provides RecipeTypographyDefault,
            LocalRecipeSpacing provides RecipeSpacingDefault,
            LocalRecipeShapes provides RecipeShapesDefault,
            LocalRecipeGlass provides RecipeGlassDefault,
        ) {
            content()
        }
    }
}
```

After:
```kotlin
@Composable
fun RecipeTheme(content: @Composable () -> Unit) {
    val colors = if (isSystemInDarkTheme()) DarkRecipeColors else LightRecipeColors
    val glassBackend = koinInject<GlassBackend>()
    MaterialTheme(colorScheme = colors.toMaterialColorScheme()) {
        CompositionLocalProvider(
            LocalRecipeColors provides colors,
            LocalRecipeTypography provides RecipeTypographyDefault,
            LocalRecipeSpacing provides RecipeSpacingDefault,
            LocalRecipeShapes provides RecipeShapesDefault,
            LocalRecipeGlass provides RecipeGlassDefault,
            LocalGlassBackend provides glassBackend,
        ) {
            content()
        }
    }
}
```

The exact symbol names (`LightRecipeColors`, `RecipeTypographyDefault`, `toMaterialColorScheme`)
depend on what plan 02.1-02 produced. The contract that matters: `LocalGlassBackend`
is now provided via `koinInject<GlassBackend>()` at the same level as the other Recipe locals.

Append-only: do not remove any existing `provides` entry. Do not change the
`MaterialTheme(...)` wrapper (legacy auth screens still depend on it — Open Questions Q3).
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `grep -c 'val shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1 - All 7 VMs registered: `grep -cE 'viewModel<(ShellViewModel|PlannerViewModel|RecipesViewModel|PantryViewModel|ShoppingViewModel|RecipesSearchViewModel|PantrySearchViewModel)>\(\)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 7 - GlassBackend single registered via resolveGlassBackend: `grep -c 'single' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1 - GlassBackend single uses isDebugBuild: `grep -c 'isDebug = isDebugBuild' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1 - GlassBackend single defaults to Liquid: `grep -c 'default = GlassBackend.Liquid' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt` returns 1 - AppModule extended: `grep -c 'shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns at least 1 - AppModule includes 3 modules: `grep -c 'includes(authModule, userModule, shellModule)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns 1 - RecipeTheme provides LocalGlassBackend: `grep -c 'LocalGlassBackend provides' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns 1 - RecipeTheme uses koinInject: `grep -c 'koinInject' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns 1 - MaterialTheme wrapper preserved (Open Questions Q3): `grep -c 'MaterialTheme' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt` returns at least 1 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 shellModule registers 7 VMs + 1 GlassBackend single; AppModule pulls it in; RecipeTheme provides LocalGlassBackend via koinInject so all descendants of RecipeTheme see the resolved backend. Task 2: Replace TabHomePlaceholder stubs in RootNavHost.kt with real Tab*Screen calls + per-tab VM scoping composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt — current state from plan 02.1-04 (placeholder stubs in each tab's composable<*Home> block) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt — from plan 02.1-07 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt — from plan 02.1-07 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt — from plan 02.1-07 - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt — from plan 02.1-07 - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pattern 2 (lines 343-360) — verbatim koinViewModel(viewModelStoreOwner = parent) idiom - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § Tab screens (lines 206-238) + § App.kt (lines 99-122) Open `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` (state from plan 02.1-04 has 4 placeholder `TabHomePlaceholder(...)` calls).
Replace each `TabHomePlaceholder(name = "...", parent = parent)` call with a real
`koinViewModel<TabViewModel>(viewModelStoreOwner = parent)` lookup followed by the
real `Tab*Screen(viewModel = vm)` call. Then DELETE the now-unused
`TabHomePlaceholder` private composable at the bottom of the file.

Required new imports:
```kotlin
import dev.ulfrx.recipe.ui.screens.planner.PlannerScreen
import dev.ulfrx.recipe.ui.screens.planner.PlannerViewModel
import dev.ulfrx.recipe.ui.screens.recipes.RecipesScreen
import dev.ulfrx.recipe.ui.screens.recipes.RecipesViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantryScreen
import dev.ulfrx.recipe.ui.screens.pantry.PantryViewModel
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingScreen
import dev.ulfrx.recipe.ui.screens.shopping.ShoppingViewModel
import org.koin.compose.viewmodel.koinViewModel
```

Imports to REMOVE:
```kotlin
import androidx.compose.foundation.text.BasicText  // (or whatever placeholder Text was used)
import androidx.compose.foundation.layout.Box      // if no longer needed elsewhere in the file
```

Resulting per-tab block (Planner shown — repeat for Recipes / Pantry / Shopping):
```kotlin
navigation<PlannerGraph>(startDestination = PlannerHome) {
    composable<PlannerHome> { entry ->
        val parent = remember(entry) {
            navController.getBackStackEntry(PlannerGraph)
        }
        val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
        PlannerScreen(viewModel = vm)
    }
    // future: composable<PlannerDetail>{ ... }
}
```

Same shape for the other three tabs:
```kotlin
navigation<RecipesGraph>(startDestination = RecipesHome) {
    composable<RecipesHome> { entry ->
        val parent = remember(entry) { navController.getBackStackEntry(RecipesGraph) }
        val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
        RecipesScreen(viewModel = vm)
    }
}

navigation<PantryGraph>(startDestination = PantryHome) {
    composable<PantryHome> { entry ->
        val parent = remember(entry) { navController.getBackStackEntry(PantryGraph) }
        val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent)
        PantryScreen(viewModel = vm)
    }
}

navigation<ShoppingGraph>(startDestination = ShoppingHome) {
    composable<ShoppingHome> { entry ->
        val parent = remember(entry) { navController.getBackStackEntry(ShoppingGraph) }
        val vm: ShoppingViewModel = koinViewModel(viewModelStoreOwner = parent)
        ShoppingScreen(viewModel = vm)
    }
}
```

DELETE the trailing `private fun TabHomePlaceholder(...)` composable that was added
by plan 02.1-04 — it has no remaining call sites.

The `// TODO(02.1-08): replace with ...` comments should also be deleted (the work
they reference is done).
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - All 4 Tab*Screen composables called: `grep -cE '(PlannerScreen|RecipesScreen|PantryScreen|ShoppingScreen)\(viewModel = vm\)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4 - All 4 koinViewModel calls with viewModelStoreOwner: `grep -c 'koinViewModel(viewModelStoreOwner = parent)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4 - All 4 getBackStackEntry calls remain: `grep -c 'getBackStackEntry' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4 - TabHomePlaceholder is deleted: `grep -c 'TabHomePlaceholder' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0 - TODO markers from plan 02.1-04 are cleared: `grep -c 'TODO(02.1-08)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0 - All 4 navigation<*Graph> blocks preserved: `grep -cE 'navigation<(Planner|Recipes|Pantry|Shopping)Graph>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 4 - startDestination = PlannerGraph preserved: `grep -c 'startDestination = PlannerGraph' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 1 - Material 3 boundary still preserved: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt` returns 0 - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 RootNavHost wires the four real tab screens with per-tab VM scoping per RESEARCH § Pattern 2; all placeholder code is gone; tab navigation graph is the production shape feature phases inherit. Task 3: Swap App.kt's Authenticated branch from PostLoginPlaceholderScreen to AppShell + extract testable RootRouter composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt — current state (the Authenticated branch on lines 48-58) - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt — AuthState enum/sealed - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt — from plan 02.1-05 - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § App.kt (lines 99-122) — modification contract - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md line 101 — keep PostLoginPlaceholderScreen as logout-bridge possibility - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Open Questions Q3 (RESOLVED) — auth screens stay as Material 3 legacy Open `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt`.
Step 1 — extract a pure routing helper function so the routing logic is unit-testable
(V-04 anchor). Add at the top of the file (after imports, before `@Composable fun App()`):

```kotlin
/**
 * Pure routing decision for [App] — facilitates unit testing of the auth gate.
 * Maps an [AuthState] + nullable currentUser to one of three top-level branches.
 */
enum class RootRoute { Splash, Login, Shell }

/**
 * Pure helper — returned route is what [App] should render. Unit-tested in
 * AppShellGateTest (V-04).
 */
internal fun resolveRootRoute(authState: AuthState, hasCurrentUser: Boolean): RootRoute =
    when (authState) {
        AuthState.Loading -> RootRoute.Splash
        AuthState.Unauthenticated -> RootRoute.Login
        AuthState.Authenticated -> if (hasCurrentUser) RootRoute.Shell else RootRoute.Splash
    }
```

Step 2 — modify the `App()` composable body. Replace lines 43-58 (the `when (authState) { ... }` block) with a use of `resolveRootRoute(...)`:

Before:
```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>(),
            )
        }
    }
}
```

After:
```kotlin
when (resolveRootRoute(authState, hasCurrentUser = currentUser != null)) {
    RootRoute.Splash -> SplashScreen()
    RootRoute.Login -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
    RootRoute.Shell -> AppShell()
}
```

Step 3 — clean up imports. ADD:
```kotlin
import dev.ulfrx.recipe.ui.screens.shell.AppShell
```

REMOVE (no longer used in the routing branch — but keep them if anything else in the
file still references them; at the time this plan runs, the only reference site
was the placeholder branch, so they should be safe to drop):
```kotlin
import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
```

HOWEVER: per CONTEXT line 101 + RESEARCH § Open Questions Q3 (RESOLVED), DO NOT
delete the `PostLoginPlaceholderScreen.kt` and `PostLoginViewModel.kt` source files
themselves. They remain in the codebase as a logout-bridge possibility — a future
phase may revive them or repurpose them. Only the imports and the call site in App.kt
are removed.

Step 4 — preserve the rest of the file:
- The `@Composable @Preview fun App()` declaration
- The `RecipeTheme { ... }` wrapper
- The `koinInject<AuthSession>()` and `koinInject<UserRepository>()` calls
- The `collectAsStateWithLifecycle()` observations
- The `LaunchedEffect(authSession) { authSession.initialize() }` block — this is
  load-bearing per CONTEXT and the docstring on line 20-25.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - Authenticated branch routes to AppShell: `grep -c 'RootRoute.Shell -> AppShell()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - PostLoginPlaceholderScreen no longer called in App.kt: `grep -c 'PostLoginPlaceholderScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 0 - PostLoginViewModel no longer imported / called in App.kt: `grep -c 'PostLoginViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 0 - Pure routing helper extracted: `grep -c 'fun resolveRootRoute' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - RootRoute enum declared: `grep -c 'enum class RootRoute' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - LaunchedEffect preserved: `grep -c 'LaunchedEffect(authSession)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - RecipeTheme wrapper preserved: `grep -c 'RecipeTheme {' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - SplashScreen still used: `grep -c 'SplashScreen()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns at least 1 - LoginScreen still used: `grep -c 'LoginScreen(' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - AppShell imported: `grep -c 'import dev.ulfrx.recipe.ui.screens.shell.AppShell' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - PostLoginPlaceholderScreen.kt + PostLoginViewModel.kt source files still exist on disk: `test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt && test -f composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginViewModel.kt` - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 App() routes Authenticated+user to AppShell instead of PostLoginPlaceholderScreen. The pure routing helper resolveRootRoute is extracted and ready for V-04 unit testing. PostLoginPlaceholderScreen / PostLoginViewModel source files remain on disk per Open Questions Q3. Task 4: Replace @Ignore stub in AppShellGateTest.kt with real assertion that resolveRootRoute(Authenticated, hasUser=true) → Shell (V-04) composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt — current Wave-0 stub - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt — for resolveRootRoute helper just-added - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/auth/AuthState.kt — AuthState shape - composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/auth/AuthSessionTest.kt — kotlin.test pattern shape - .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-VALIDATION.md § Per-Task Verification Map V-04 (line 49) Replace the Wave-0 `@Ignore`'d body of `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` with:
```kotlin
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 lines 20-25: 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)
    }
}
```

Drop the `@Ignore` import and annotation. Use `kotlin.test` only.

Note: the imports `dev.ulfrx.recipe.RootRoute` and `dev.ulfrx.recipe.resolveRootRoute`
target the helpers added in App.kt (top-level declarations in the `dev.ulfrx.recipe`
package). Confirm the package matches App.kt's `package dev.ulfrx.recipe` line.
`resolveRootRoute` should be `internal` (visible from commonTest in the same module).
./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.AppShellGateTest" -q - `grep -c '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 0 - `grep -c '@Test' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns at least 5 - V-04 anchor test name present: `grep -c 'authenticatedWithUser_routesToShell_notPlaceholder' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1 - Two-layer gate covered: `grep -c 'authenticatedWithoutUserYet_routesToSplash' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1 - Imports resolveRootRoute: `grep -c 'import dev.ulfrx.recipe.resolveRootRoute' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1 - Imports RootRoute: `grep -c 'import dev.ulfrx.recipe.RootRoute' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` returns 1 - `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.AppShellGateTest" -q` exits 0 AppShellGateTest contains 5 passing assertions covering all four AuthState × hasCurrentUser combinations. V-04 anchor backed by real assertions; UI-09's auth-gate-to-shell routing is deterministically tested. - iOS K/N compile green: `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0 - Android compile green: `./gradlew :composeApp:compileDebugKotlinAndroid -q` exits 0 - Full commonTest green: `./gradlew :composeApp:commonTest -q` exits 0 - Full check green: `./gradlew :composeApp:check -q` exits 0 - iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0 - All V-anchors V-01..V-07 are now covered by passing tests (no @Ignore left in any test file): `grep -rE '@Ignore' composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ | wc -l` returns 0 - App.kt routes Authenticated+user to AppShell: `grep -c 'RootRoute.Shell -> AppShell()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt` returns 1 - AppModule pulls in shellModule: `grep -c 'shellModule' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns at least 1 - Material 3 boundary preserved across plan-08 changes: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt` returns 0 - PostLoginPlaceholderScreen.kt + PostLoginViewModel.kt source files preserved on disk - Wave 0 ALL test stubs un-ignored across the phase

<success_criteria>

  1. ShellModule.kt registers 7 ViewModels (ShellViewModel, 4 tab VMs, 2 Search VMs) and 1 GlassBackend single resolved via resolveGlassBackend(get(), isDebugBuild, default = GlassBackend.Liquid).
  2. AppModule.kt's includes(...) pulls in shellModule alongside authModule + userModule.
  3. RecipeTheme.kt provides LocalGlassBackend via koinInject() at the same level as other Recipe locals; the MaterialTheme(...) wrapper is preserved (Open Questions Q3 RESOLVED — legacy auth screens keep working).
  4. RootNavHost.kt's four TabHomePlaceholder stubs are replaced with real koinViewModel<*ViewModel>(viewModelStoreOwner = parent) lookups followed by *Screen(viewModel = vm) calls (RESEARCH § Pattern 2). The placeholder helper is deleted.
  5. App.kt routes Authenticated + currentUser != nullAppShell() via the extracted pure resolveRootRoute(...) helper. LaunchedEffect(authSession) { initialize() } and currentUser == null → SplashScreen() arms are preserved. PostLoginPlaceholderScreen / PostLoginViewModel source files stay on disk per CONTEXT line 101.
  6. V-04 anchor: AppShellGateTest passes 5 assertions covering all AuthState × hasCurrentUser combinations.
  7. No @Ignore'd tests remain anywhere in commonTest — all Wave-0 stubs are now backed by real assertions (V-01..V-07).
  8. Full ./gradlew :composeApp:check green.
  9. UI-09 final closure: signed-in user lands in the real shell with all four tabs accessible; default landing tab is Planner (D-03); each tab renders its anticipatory empty state (D-10/D-11); search affordance visible only on Recipes + Pantry (D-06). </success_criteria>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-08-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record: - Whether `Settings` was already registered in Koin commonMain (Phase 2 wiring) or whether shellModule had to register it. - The final exact form of the RecipeTheme.kt edit (which `provides` line was added; preserved structure). - Confirmation that PostLoginPlaceholderScreen.kt and PostLoginViewModel.kt source files remain on disk (logout-bridge per Open Questions Q3 RESOLVED). - Manual smoke test results from V-08 / V-09 / V-10 / V-11 (iOS simulator runbook): default Planer landing, tab back-stack preservation across reselect, search affordance scoped to Recipes + Pantry, Liquid dock animation visible (or flat fallback if Liquid did not resolve on the device path).