Files

716 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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)"
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<GlassBackend> { resolveGlassBackend(get<Settings>(), isDebugBuild, default) }"
pattern: "resolveGlassBackend"
- from: "ui/theme/RecipeTheme.kt"
to: "ui/components/glass/GlassBackend.kt"
via: "CompositionLocalProvider(LocalGlassBackend provides koinInject<GlassBackend>())"
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 ="
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
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> { SecureAuthStateStore(get()) }
// ...
viewModel<LoginViewModel>()
viewModel<PostLoginViewModel>()
}
```
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<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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ShellModule.kt + extend AppModule.kt + provide LocalGlassBackend in RecipeTheme</name>
<files>
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
</files>
<read_first>
- 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
</read_first>
<action>
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).
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- `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<GlassBackend>' 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<GlassBackend>: `grep -c 'koinInject<GlassBackend>' 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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Replace TabHomePlaceholder stubs in RootNavHost.kt with real Tab*Screen calls + per-tab VM scoping</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Swap App.kt's Authenticated branch from PostLoginPlaceholderScreen to AppShell + extract testable RootRouter</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/App.kt</files>
<read_first>
- 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
</read_first>
<action>
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.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 4: Replace @Ignore stub in AppShellGateTest.kt with real assertion that resolveRootRoute(Authenticated, hasUser=true) → Shell (V-04)</name>
<files>composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<verify>
<automated>./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.AppShellGateTest" -q</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
- 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
</verification>
<success_criteria>
1. ShellModule.kt registers 7 ViewModels (ShellViewModel, 4 tab VMs, 2 Search VMs) and 1 GlassBackend single resolved via resolveGlassBackend(get<Settings>(), isDebugBuild, default = GlassBackend.Liquid).
2. AppModule.kt's `includes(...)` pulls in shellModule alongside authModule + userModule.
3. RecipeTheme.kt provides LocalGlassBackend via koinInject<GlassBackend>() 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).
</success_criteria>
<output>
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).
</output>