--- phase: 02.1 plan: 05 subsystem: ui-shell tags: [kotlin, compose-multiplatform, shell, dock, viewmodel, glass, accessibility, navigation] requires: - 02.1-03 # GlassSurface + GlassBackdropSource - 02.1-04 # BottomBarDestination + RootNavHost + navigateToTab + a11y string keys - 02.1-06 # SearchPill + RecipesSearchViewModel + PantrySearchViewModel provides: - "ShellViewModel + ShellState (StateFlow + method-per-action)" - "AppShell() — authenticated root composable" - "DockBar() — collapsible 4-tab Liquid-glass dock" - "FloatingSearchButton() — 44dp circular glass button" affects: - "Empty placeholder app target now has the destination composable for the post-auth shell (plan 02.1-08 wires it in)." tech-stack: added: [] patterns: - "VM + StateFlow + method-per-action (mirrors LoginViewModel)" - "animateContentSize + AnimatedContent single-block animation at 250ms FastOutSlowInEasing" - "Type-safe NavBackStackEntry → BottomBarDestination derivation via hasRoute(*Graph::class)" key-files: created: - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt - composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt modified: [] decisions: - "ShellViewModel holds activeTab + searchOpen only; query state lives in per-tab Search VMs (RecipesSearchViewModel, PantrySearchViewModel) so Phase 5's extension hook stays connected to the UI." - "DockBar uses Row + Modifier.semantics{role=Role.Tab; selected; contentDescription} (the UI-SPEC-line-180 'TabGroup-equivalent fallback') instead of Compose Unstyled's TabGroup primitive — the renderless TabGroup did not match the desired per-cell semantics shape; PATTERNS.md § DockBar line 326 explicitly accepts this path." - "Active tab derivation uses type-safe hasRoute(*Graph::class) on the destination hierarchy — no string-route fallback was needed." - "ShellViewModel ↔ NavHost sync uses a LaunchedEffect(activeTab) instead of an inline if-state-check, to avoid composition-side-effect pitfalls." metrics: completed: 2026-05-08 duration: ~25 minutes --- # Phase 02.1 Plan 05: App Shell Composables Summary Built the four core authenticated-shell composables — `ShellViewModel`, `AppShell`, `DockBar`, `FloatingSearchButton` — wiring RootNavHost (02.1-04) inside a GlassBackdropSource (02.1-03) and overlaying a bottom chrome column with the SearchPill (02.1-06), DockBar, and FloatingSearchButton. ## What Was Built 1. **ShellViewModel + ShellState** — pure synchronous state machine with three method-per-action signatures (`openSearch`, `closeSearch`, `onTabChanged`). State is `(activeTab, searchOpen)` only — no query field; per-tab query state lives in `RecipesSearchViewModel` / `PantrySearchViewModel`. Mirrors LoginViewModel's StateFlow shape. 2. **DockBar** — Liquid-glass capsule rendering 4 tabs (icon + label always shown, D-02) when expanded (28dp corner, 56dp tall) and collapsing to a single circular icon-only toggle on the active tab when search opens (22dp corner, 44dp tall, D-05). The collapse is one coordinated motion: `animateContentSize` on the GlassSurface modifier plus `AnimatedContent` with a fade `togetherWith` transition, both at 250ms FastOutSlowInEasing per UI-SPEC line 198. Each tab cell exposes `Role.Tab + selected + contentDescription` semantics; cells satisfy ≥44dp touch targets via `defaultMinSize(44dp, 44dp)`. 3. **FloatingSearchButton** — 44dp `GlassSurface(cornerRadius = 22.dp)` with `Icons.Outlined.Search` tinted `RecipeTheme.colors.content`. Carries `search_open_a11y` contentDescription. Visibility (only when `!searchOpen && activeTab.hasSearch`) is gated by AppShell, not the button itself. 4. **AppShell** — authenticated root composable. Wraps `RootNavHost` in `GlassBackdropSource` so Liquid/Haze backends sample the body through the shared `LocalGlassBackdropState`. Bottom chrome is a `Column` aligned `BottomCenter` with `windowInsetsPadding(WindowInsets.navigationBars) + imePadding()` only — no `safeContentPadding()` per Pitfall F. Conditionally renders a `SearchPill` wired to the active tab's SearchViewModel (Recipes or Pantry — both paths covered) above the always-present `DockBar`. The `FloatingSearchButton` is overlaid at `BottomEnd`. Active-tab tracking derives from `NavBackStackEntry.destination.hierarchy` via type-safe `hasRoute(*Graph::class)`; a `LaunchedEffect(activeTab)` keeps `ShellViewModel.activeTab` in sync for back-button and deep-link cases. Tab selection navigates via `navigateToTab(dest.graphRoute)` and notifies `vm.onTabChanged(dest)`. ## Plan Output Questions Answered - **Both Recipes and Pantry SearchViewModel paths covered?** Yes — `AppShell` has explicit `when (activeTab)` branches for both `BottomBarDestination.Recipes` and `BottomBarDestination.Pantry` for SearchPill rendering and FloatingSearchButton onClick. `Planner` and `Shopping` are no-ops because `hasSearch = false` already gates the surfaces. - **Compose Unstyled TabGroup vs Row + semantics?** Used the `Row + Modifier.semantics { role = Role.Tab; selected; contentDescription }` fallback per UI-SPEC line 180 / PATTERNS.md § DockBar line 326. The renderless TabGroup did not offer a cleaner per-cell shape than direct semantics modifiers. - **`hasRoute(*Graph::class)` worked?** Yes — nav-compose 2.9.2 exposes `NavDestination.Companion.hasRoute` and `NavDestination.Companion.hierarchy`. No string-route fallback was needed; iOS K/N compile + linkDebugFrameworkIosSimulatorArm64 both green. - **Touch targets:** Code-level confirmation — DockTabCell uses `defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)`, CollapsedDockToggle is `size(44.dp)`, FloatingSearchButton is `size(44.dp)`. Visual sim confirmation deferred to plan 02.1-08's manual smoke (V-09 / V-11 in VALIDATION.md). ## Deviations from Plan None — plan executed exactly as written. The plan's "Implementation note 1" pre-flagged an invalid `clickableNoRipple` sketch and recommended the `MutableInteractionSource + clickable(indication = null)` pattern, which is what the final code uses inline at each click site. No autoFix Rules 1–3 needed. ## Verification - `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` → exits 0 (silent). - `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` → exits 0 (only an unrelated bundle-ID warning). - Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 4 new files. - Direct Liquid / Haze imports zero in `ui/screens/shell/` and `ui/components/dock/`. - `safeContentPadding()` not present in AppShell. ## Commits | Task | Commit | Files | | --- | --- | --- | | 1 — ShellViewModel | `5e0aaf9` | ShellViewModel.kt | | 2 — DockBar + FloatingSearchButton | `78bb90d` | DockBar.kt, FloatingSearchButton.kt | | 3 — AppShell | `fb4301e` | AppShell.kt | ## Self-Check: PASSED - Files exist: - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt - FOUND: composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt - Commits exist: FOUND 5e0aaf9, 78bb90d, fb4301e. - iOS compile + link both green.