42 KiB
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 |
|
|
true |
|
|
|
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 paramsdev.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.
```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>
- ShellModule.kt registers 7 ViewModels (ShellViewModel, 4 tab VMs, 2 Search VMs) and 1 GlassBackend single resolved via resolveGlassBackend(get(), isDebugBuild, default = GlassBackend.Liquid).
- AppModule.kt's
includes(...)pulls in shellModule alongside authModule + userModule. - 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).
- 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. - App.kt routes
Authenticated + currentUser != null→AppShell()via the extracted pureresolveRootRoute(...)helper.LaunchedEffect(authSession) { initialize() }andcurrentUser == null → SplashScreen()arms are preserved. PostLoginPlaceholderScreen / PostLoginViewModel source files stay on disk per CONTEXT line 101. - V-04 anchor: AppShellGateTest passes 5 assertions covering all AuthState × hasCurrentUser combinations.
- No @Ignore'd tests remain anywhere in commonTest — all Wave-0 stubs are now backed by real assertions (V-01..V-07).
- Full
./gradlew :composeApp:checkgreen. - 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>