--- phase: 02.1 plan: 08 type: execute wave: 5 depends_on: ["02.1-05", "02.1-06", "02.1-07"] files_modified: - 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 autonomous: true requirements: [UI-09] tags: [kotlin, koin, di, app-entry, navigation, glass, expect-actual, integration, multiplatform-settings] must_haves: truths: - "shellModule registers all 4 tab VMs (PlannerViewModel, RecipesViewModel, PantryViewModel, ShoppingViewModel), both Search VMs (RecipesSearchViewModel, PantrySearchViewModel), ShellViewModel, and a single { resolveGlassBackend(get(), 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)" artifacts: - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/ShellModule.kt" provides: "Koin shellModule — 7 VMs + GlassBackend single" contains: "val shellModule" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/di/AppModule.kt" provides: "appModule extended to include shellModule" contains: "shellModule" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt" provides: "App() composable routing Authenticated+user to AppShell()" contains: "AppShell()" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt" provides: "RootNavHost wired to call PlannerScreen / RecipesScreen / PantryScreen / ShoppingScreen with VM scoping" contains: "PlannerScreen" - path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt" provides: "RecipeTheme provides LocalGlassBackend value resolved at startup" contains: "LocalGlassBackend provides" - path: "composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt" provides: "V-04 anchor — real assertion that Authenticated+user routes to AppShell branch" key_links: - from: "App.kt" to: "ui/screens/shell/AppShell.kt" via: "Authenticated branch invokes AppShell() instead of PostLoginPlaceholderScreen(...)" pattern: "AppShell\\(\\)" - from: "di/AppModule.kt" to: "di/ShellModule.kt" via: "includes(authModule, userModule, shellModule)" pattern: "shellModule" - from: "di/ShellModule.kt" to: "ui/components/glass/GlassBackend.kt" via: "single { resolveGlassBackend(get(), isDebugBuild, default) }" pattern: "resolveGlassBackend" - from: "ui/theme/RecipeTheme.kt" to: "ui/components/glass/GlassBackend.kt" via: "CompositionLocalProvider(LocalGlassBackend provides koinInject())" pattern: "LocalGlassBackend" - from: "navigation/RootNavHost.kt" to: "ui/screens/{planner,recipes,pantry,shopping}/{Tab}Screen.kt" via: "composable<*Home>{ koinViewModel(viewModelStoreOwner = parent) → Tab*Screen(viewModel = vm) }" pattern: "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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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: ```kotlin val authModule = module { single { SecureAuthStateStore(get()) } // ... viewModel() viewModel() } ``` Existing AppModule (`di/AppModule.kt`): ```kotlin 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): ```kotlin when (authState) { AuthState.Loading -> SplashScreen() AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel()) AuthState.Authenticated -> { val user = currentUser if (user == null) { SplashScreen() } else { PostLoginPlaceholderScreen( user = user, viewModel = koinViewModel(), ) } } } ``` 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 { resolveGlassBackend( settings = get(), isDebug = isDebugBuild, default = GlassBackend.Liquid, ) } // Shell-level state machine. viewModel() // Tab ViewModels — empty-state-only this phase; feature phases extend them. viewModel() viewModel() viewModel() viewModel() // Per-tab Search ViewModels — pure echo this phase; Phase 5 / 8 inject // their respective SearchSource implementations. viewModel() viewModel() } ``` 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()` 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 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()` at startup. Read the current RecipeTheme.kt (post plan 02.1-02). Locate the `CompositionLocalProvider(...)` block. Add `LocalGlassBackend provides koinInject()` 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() 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()` 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(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(startDestination = PlannerHome) { composable { entry -> val parent = remember(entry) { navController.getBackStackEntry(PlannerGraph) } val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent) PlannerScreen(viewModel = vm) } // future: composable{ ... } } ``` Same shape for the other three tabs: ```kotlin navigation(startDestination = RecipesHome) { composable { entry -> val parent = remember(entry) { navController.getBackStackEntry(RecipesGraph) } val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent) RecipesScreen(viewModel = vm) } } navigation(startDestination = PantryHome) { composable { entry -> val parent = remember(entry) { navController.getBackStackEntry(PantryGraph) } val vm: PantryViewModel = koinViewModel(viewModelStoreOwner = parent) PantryScreen(viewModel = vm) } } navigation(startDestination = ShoppingHome) { composable { 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()) AuthState.Authenticated -> { val user = currentUser if (user == null) { SplashScreen() } else { PostLoginPlaceholderScreen( user = user, viewModel = koinViewModel(), ) } } } ``` After: ```kotlin when (resolveRootRoute(authState, hasCurrentUser = currentUser != null)) { RootRoute.Splash -> SplashScreen() RootRoute.Login -> LoginScreen(viewModel = koinViewModel()) 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()` and `koinInject()` 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 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 != null` → `AppShell()` 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). 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).