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

906 lines
52 KiB
Markdown

---
phase: 02.1
plan: 05
type: execute
wave: 4
depends_on: ["02.1-03", "02.1-04", "02.1-06"]
files_modified:
- 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
autonomous: true
requirements: [UI-03, UI-04, UI-09]
tags: [kotlin, compose-multiplatform, shell, dock, viewmodel, glass, compose-unstyled, accessibility, navigation]
must_haves:
truths:
- "AppShell is the authenticated root composable; takes no params; consumes koinViewModel<ShellViewModel>() and rememberNavController()"
- "ShellViewModel exposes ShellState(activeTab, searchOpen) via StateFlow with method-per-action: openSearch / closeSearch / onTabChanged; per-tab query state stays in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06"
- "closeSearch() sets searchOpen=false and AppShell also closes/clears the active tab's SearchViewModel (D-08)"
- "DockBar renders 4 tabs (icon + label always shown — D-02) when collapsed=false; renders single circular icon-only toggle when collapsed=true (D-05)"
- "DockBar collapse animation is a single coordinated motion using Modifier.animateContentSize() + AnimatedContent at 250ms FastOutSlowInEasing (UI-SPEC line 198)"
- "FloatingSearchButton renders a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search; visible only when !searchOpen && activeTab.hasSearch"
- "AppShell applies GlassBackdropSource behind RootNavHost so Liquid/Haze chrome samples the screen body through the shared LocalGlassBackdropState"
- "SearchPill reads/writes the active tab SearchViewModel: RecipesSearchViewModel on Recipes, PantrySearchViewModel on Pantry; ShellViewModel only coordinates shell visibility and active tab"
- "Bottom chrome consumes WindowInsets.navigationBars explicitly; AppShell does NOT use safeContentPadding() to avoid double-inset (Pitfall F)"
- "Direct Liquid / Haze API imports stay confined to ui/components/glass/ — DockBar / FloatingSearchButton / SearchPill consume GlassSurface only"
- "Material 3 imports ZERO in any new file (CLAUDE.md / UI-SPEC line 31)"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt"
provides: "ShellViewModel + ShellState data class"
contains: "class ShellViewModel"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt"
provides: "AppShell() composable — authenticated root"
contains: "fun AppShell"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt"
provides: "DockBar composable with collapse-on-search animation"
contains: "fun DockBar"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt"
provides: "FloatingSearchButton composable — 44dp circular glass button"
contains: "fun FloatingSearchButton"
key_links:
- from: "ui/screens/shell/AppShell.kt"
to: "ui/screens/shell/ShellViewModel.kt"
via: "val vm: ShellViewModel = koinViewModel(); val ui by vm.state.collectAsStateWithLifecycle()"
pattern: "ShellViewModel"
- from: "ui/screens/shell/AppShell.kt"
to: "navigation/RootNavHost.kt"
via: "RootNavHost(navController) renders as the body"
pattern: "RootNavHost"
- from: "ui/screens/shell/AppShell.kt"
to: "ui/components/dock/DockBar.kt"
via: "renders DockBar(... collapsed = ui.searchOpen, onCollapsedTap = { closeActiveSearch() })"
pattern: "DockBar"
- from: "ui/screens/shell/AppShell.kt"
to: "ui/components/dock/FloatingSearchButton.kt"
via: "conditional render when !ui.searchOpen && activeTab.hasSearch"
pattern: "FloatingSearchButton"
- from: "ui/components/dock/DockBar.kt"
to: "ui/components/glass/GlassSurface.kt"
via: "GlassSurface(cornerRadius = 28.dp / 22.dp) substrate per UI-SPEC line 253"
pattern: "GlassSurface"
---
<objective>
Build the four core shell composables — `ShellViewModel` (state machine for activeTab + searchOpen only), `AppShell` (authenticated root composable hosting RootNavHost + bottom chrome overlay), `DockBar` (4-tab Liquid-glass pill that collapses to a single circular icon toggle when search opens — D-05 single coordinated motion), and `FloatingSearchButton` (44dp circular glass button visible only on Recipes + Pantry — D-06). All chrome consumes the `GlassSurface` primitive from plan 02.1-03; layout follows RESEARCH § Code Example 2 (lines 514-565). The dock-collapse-on-search transition is a single `animateContentSize() + AnimatedContent` block driven by `ShellState.searchOpen`.
`SearchPill` is NOT part of this plan — it is owned by plan 02.1-06, and this plan depends on 02.1-06 so `AppShell` can import it directly without temporary stubs. `AppShell` wires that pill to the active tab's search ViewModel (RecipesSearchViewModel or PantrySearchViewModel) rather than duplicating query state in ShellViewModel.
Per CONTEXT D-04 there is no top app bar — tab title is rendered inline by each tab screen (plan 02.1-07). AppShell is purely chrome + NavHost.
Purpose: UI-03 + UI-04 — the floating Liquid-glass dock with bottom-anchored chrome is the visible identity of this phase. UI-09 — the shell exists, replacing the placeholder, so empty states have a place to render (plan 02.1-08 makes the swap; this plan creates the destination composable).
Output: 4 new commonMain files. Build is green; no automated tests added (visible chrome is verified in V-09 / V-11 manual smokes — VALIDATION.md line 54-56).
</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
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt
<interfaces>
After Wave 3 (plan 02.1-06 plus its prerequisites 02.1-03/04) lands:
From plan 02.1-03 (`ui/components/glass/`):
```kotlin
package dev.ulfrx.recipe.ui.components.glass
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
tint: Color = RecipeTheme.colors.surfaceGlass,
cornerRadius: Dp = 28.dp,
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
content: @Composable BoxScope.() -> Unit,
)
enum class GlassBackend { Liquid, Haze, Flat }
val LocalGlassBackend: ProvidableCompositionLocal<GlassBackend>
fun resolveGlassBackend(settings: Settings, isDebug: Boolean, default: GlassBackend): GlassBackend
expect val isDebugBuild: Boolean
```
From plan 02.1-04 (`navigation/`):
```kotlin
package dev.ulfrx.recipe.navigation
@Serializable data object PlannerGraph
@Serializable data object RecipesGraph
@Serializable data object PantryGraph
@Serializable data object ShoppingGraph
enum class BottomBarDestination(
val graphRoute: Any,
val labelRes: StringResource,
val icon: ImageVector,
val hasSearch: Boolean,
val searchPlaceholder: StringResource?,
) {
Planner, Recipes, Pantry, Shopping;
companion object { val Default: BottomBarDestination = Planner }
}
@Composable fun RootNavHost(navController: NavHostController, modifier: Modifier = Modifier)
fun NavHostController.navigateToTab(graphRoute: Any)
```
From plan 02.1-02 (`ui/theme/`):
```kotlin
object RecipeTheme {
val colors: RecipeColors @Composable @ReadOnlyComposable get()
val typography: RecipeTypography @Composable @ReadOnlyComposable get()
val spacing: RecipeSpacing @Composable @ReadOnlyComposable get()
val shapes: RecipeShapes @Composable @ReadOnlyComposable get()
val glass: RecipeGlass @Composable @ReadOnlyComposable get()
}
// RecipeColors: background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard, destructive (all Color)
// RecipeTypography: display, title, body, label (all TextStyle)
// RecipeSpacing: xs (4dp), sm (8dp), lg (16dp), xl (24dp), `2xl` (32dp), `3xl` (48dp) — accessor names use Kotlin valid identifiers (likely `xs`, `sm`, `lg`, `xl`, `xxl`, `xxxl` or backticked — verify exact names from RecipeSpacing.kt)
```
NOTE: Verify RecipeSpacing accessor names by reading the file before use. UI-SPEC § Spacing names them `xs/sm/lg/xl/2xl/3xl` but Kotlin identifiers cannot start with a digit, so plan 02.1-02 must have remapped `2xl``xxl` (or backticked them). Treat the canonical accessor names as whatever plan 02.1-02 produced; UI-SPEC's friendly names are a contract on VALUES, not on identifier names.
LoginViewModel pattern (`LoginViewModel.kt:37-55`) — mirror this shape:
```kotlin
class XxxViewModel(...) : ViewModel() {
private val _state = MutableStateFlow(XxxState())
val state: StateFlow<XxxState> = _state.asStateFlow()
fun action() { _state.update { ... } }
}
```
LoginScreen pattern (`LoginScreen.kt:39-43`) — mirror VM observation:
```kotlin
@Composable
fun XxxScreen(viewModel: XxxViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
// ...
}
```
Compose Unstyled API (`com.composables:composeunstyled:1.49.9`) — used by DockBar:
- `TabGroup` renderless primitive — explore the artifact's exports; if a `TabGroup`-equivalent does not exist in 1.49, fall back to a `Row { ... }` with `Modifier.semantics { role = Role.Tab; selected = isActive }` per UI-SPEC line 220. Compose Unstyled's exact `TabGroup` shape is API-specific and the artifact should be inspected at implementation time. RESEARCH § Standard Stack line 137 names the artifact but does not pin the specific `TabGroup` API; UI-SPEC line 180 says "TabGroup-equivalent" — meaning either the library's primitive OR a custom `Row + Tab` shape is acceptable provided the a11y semantics are correct.
Compose Unstyled `Button` (UI-SPEC line 181) — used by FloatingSearchButton. Same pragmatic note: use the primitive if available; otherwise `Modifier.clickable()` on a `Box`.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create ShellViewModel + ShellState (pure StateFlow + method-per-action)</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — LoginViewModel.kt:37-55 — analog VM shape (StateFlow + method-per-action)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel (lines 151-179) — ShellState fields + methods
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-08 (line 35) — closing search clears query; AppShell delegates query clearing to the active tab SearchViewModel from plan 02.1-06
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — for BottomBarDestination.Default
</read_first>
<action>
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.shell
import androidx.lifecycle.ViewModel
import dev.ulfrx.recipe.navigation.BottomBarDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Immutable UI state for [AppShell]. The shell tracks three things:
* - [activeTab] which tab is currently selected (mirrors NavHost back-stack head).
* - [searchOpen] whether the search affordance is open (D-06: only valid when
* [activeTab].hasSearch is true).
*
* Query text deliberately lives in the active tab's SearchViewModel
* (RecipesSearchViewModel or PantrySearchViewModel from plan 02.1-06). This keeps
* Phase 5's extension hook connected to the UI that the user actually sees.
*/
data class ShellState(
val activeTab: BottomBarDestination = BottomBarDestination.Default,
val searchOpen: Boolean = false,
)
/**
* Active-tab + search state machine for the shell. Pure synchronous state
* transitions — no I/O, no viewModelScope.launch. Mirrors [LoginViewModel]'s
* VM+StateFlow+method-per-action shape (CLAUDE.md project convention).
*
* Note: per-tab Search VMs (Recipes, Pantry — plan 02.1-06) own query and clear
* behavior. ShellViewModel mirrors search OPEN status here so the dock and floating
* button can react synchronously.
*/
class ShellViewModel : ViewModel() {
private val _state = MutableStateFlow(ShellState())
val state: StateFlow<ShellState> = _state.asStateFlow()
/** D-05 / D-06: open the search affordance on the active tab. No-op if the
* active tab has no search (defensive — UI is supposed to gate the call). */
fun openSearch() {
_state.update { current ->
if (!current.activeTab.hasSearch) current
else current.copy(searchOpen = true)
}
}
/** D-08 shell half: closing hides search. AppShell also calls activeSearchVm.close(). */
fun closeSearch() {
_state.update { it.copy(searchOpen = false) }
}
/** Tab change — also closes any open search per D-08 (closing on tab switch is
* the same semantic: search state does not persist across tab switch). */
fun onTabChanged(dest: BottomBarDestination) {
_state.update { ShellState(activeTab = dest, searchOpen = false) }
}
}
```
NOTE: this VM is registered in Koin's `shellModule` by plan 02.1-08 — not here. This plan only declares the type so AppShell (next task) can reference it.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- `grep -c 'data class ShellState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
- `grep -c 'class ShellViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
- `grep -c 'val state: StateFlow<ShellState>' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 1
- All 3 shell actions defined: `grep -cE 'fun (openSearch|closeSearch|onTabChanged)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 3
- ShellState has no query field: `grep -c 'val query' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
- ShellViewModel has no onQueryChange/clearQuery methods: `grep -cE 'fun (onQueryChange|clearQuery)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
- Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>ShellViewModel mirrors the LoginViewModel pattern with StateFlow + 3 method-per-action signatures; query state stays in the tab SearchViewModels from plan 02.1-06; onTabChanged resets search visibility on tab switch.</done>
</task>
<task type="auto">
<name>Task 2: Create DockBar.kt + FloatingSearchButton.kt — chrome composables consuming GlassSurface</name>
<files>
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt
</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassSurface.kt — public API just landed in plan 02.1-03
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — enum shape from plan 02.1-04
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — for token accessor verification (RecipeTheme.spacing/typography/colors)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 180 (DockBar shape) + line 181 (FloatingSearchButton)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Interaction Contracts (lines 192-216) — collapse animation contract
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Glass / Liquid contract (lines 248-256) — corner radius 28dp dock / 22dp collapsed / 22dp button
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility (lines 219-226) — Role.Tab + contentDescription
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-01 / D-02 / D-05 / D-06 — dock geometry + collapse contract
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § DockBar lines 317-327 + § FloatingSearchButton lines 332-337
</read_first>
<action>
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
/**
* Floating bottom-anchored Liquid-glass dock per CONTEXT D-01 + UI-SPEC line 180.
*
* - Expanded (collapsed=false): all 4 tabs, icon + label always shown (D-02), active
* tab visually emphasized (wider cell + accent foreground per UI-SPEC § Color
* "Accent reserved for"). Capsule shape: 28dp corner radius, 56dp height.
*
* - Collapsed (collapsed=true): single circular cell showing only the active tab's
* icon, no label. 22dp corner radius (full-pill at 44dp height). Tapping invokes
* [onCollapsedTap] which closes the search per D-05.
*
* Single coordinated animation per D-05: the entire dock animates as one block via
* [animateContentSize] (size) + [AnimatedContent] (content swap) at 250ms with
* [FastOutSlowInEasing] per UI-SPEC line 198. Phase 10 may tune timing on real
* device.
*
* Substrate: [GlassSurface] from plan 02.1-03 — direct Liquid/Haze API calls are
* forbidden here per RESEARCH § Anti-Patterns and CLAUDE.md non-negotiable #10.
*
* Touch targets: each tab cell + collapsed toggle is ≥ 44dp (UI-SPEC line 52, 224).
*/
@Composable
fun DockBar(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
collapsed: Boolean,
onTabSelect: (BottomBarDestination) -> Unit,
onCollapsedTap: () -> Unit,
modifier: Modifier = Modifier,
) {
val cornerRadius = if (collapsed) 22.dp else 28.dp
val height = if (collapsed) 44.dp else 56.dp
GlassSurface(
modifier = modifier
.height(height)
.animateContentSize(animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing)),
cornerRadius = cornerRadius,
) {
AnimatedContent(
targetState = collapsed,
transitionSpec = {
androidx.compose.animation.fadeIn(tween(250, easing = FastOutSlowInEasing)) togetherWith
androidx.compose.animation.fadeOut(tween(250, easing = FastOutSlowInEasing))
},
label = "DockBar collapse",
) { isCollapsed ->
if (isCollapsed) {
CollapsedDockToggle(
active = active,
onTap = onCollapsedTap,
)
} else {
ExpandedDockTabs(
destinations = destinations,
active = active,
onTabSelect = onTabSelect,
)
}
}
}
}
@Composable
private fun ExpandedDockTabs(
destinations: List<BottomBarDestination>,
active: BottomBarDestination,
onTabSelect: (BottomBarDestination) -> Unit,
) {
Row(
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.sm),
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { dest ->
val isActive = dest == active
DockTabCell(
destination = dest,
isActive = isActive,
onClick = { onTabSelect(dest) },
)
}
}
}
@Composable
private fun DockTabCell(
destination: BottomBarDestination,
isActive: Boolean,
onClick: () -> Unit,
) {
val tint = if (isActive) RecipeTheme.colors.accent else RecipeTheme.colors.contentMuted
val labelText = stringResource(destination.labelRes)
Row(
modifier = Modifier
.defaultMinSize(minWidth = 44.dp, minHeight = 44.dp)
.clip(RoundedCornerShape(22.dp))
.clickableNoRipple(onClick = onClick)
.padding(horizontal = RecipeTheme.spacing.sm, vertical = RecipeTheme.spacing.xs)
.semantics {
role = Role.Tab
selected = isActive
contentDescription = labelText + (if (isActive) ", aktywna" else "")
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.xs),
) {
androidx.compose.foundation.Image(
painter = rememberVectorPainter(image = destination.icon),
contentDescription = null,
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(tint),
modifier = Modifier.size(20.dp),
)
BasicText(
text = labelText,
style = RecipeTheme.typography.label.copy(color = tint),
)
}
}
@Composable
private fun CollapsedDockToggle(
active: BottomBarDestination,
onTap: () -> Unit,
) {
val a11yLabel = stringResource(recipe.composeapp.generated.resources.Res.string.search_close_a11y)
Box(
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(22.dp))
.clickableNoRipple(onClick = onTap)
.semantics { contentDescription = a11yLabel },
contentAlignment = Alignment.Center,
) {
androidx.compose.foundation.Image(
painter = rememberVectorPainter(image = active.icon),
contentDescription = null,
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(RecipeTheme.colors.accent),
modifier = Modifier.size(22.dp),
)
}
}
/**
* Internal helper — clickable without ripple (we're inside a glass substrate; ripple
* is provided by Material 3 which is forbidden in shell code per UI-SPEC line 31).
* Phase 10 may add a custom Liquid-aware press indication.
*/
@Composable
private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier =
this.then(
Modifier.semantics(mergeDescendants = false) {}
).then(
// foundation.clickable provides press semantics + a11y without forcing Material ripple.
androidx.compose.foundation.clickable(
interactionSource = androidx.compose.foundation.interaction.MutableInteractionSource(),
indication = null,
onClick = onClick,
)
)
```
Implementation note 1: the `clickableNoRipple` extension above sketches the intent
but the API used inside `then(Modifier.foundation.clickable(...))` is invalid Kotlin
syntax — the executor must conform to the actual `Modifier.clickable(...)` extension
(it is itself a Modifier extension, not a standalone Modifier). Recommended actual
implementation:
```kotlin
@Composable
private fun Modifier.tabClickable(onClick: () -> Unit): Modifier {
val interactionSource = remember { MutableInteractionSource() }
return this.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
}
```
Required imports: `androidx.compose.foundation.clickable`, `androidx.compose.foundation.interaction.MutableInteractionSource`, `androidx.compose.runtime.remember`.
Implementation note 2: the `search_close_a11y` resource key is added by plan 02.1-04.
This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05.
Implementation note 3: `Compose Unstyled TabGroup` was the spec'd primitive (UI-SPEC
line 180). If the artifact's `TabGroup` API does not match the shape used here
(separate cells with `Modifier.semantics { role = Role.Tab }`), use the artifact's
primitive instead. The only contract that MUST hold: each cell has `role = Role.Tab`,
`selected = isActive`, and a meaningful `contentDescription`. PATTERNS.md § DockBar
line 326 confirms either path is acceptable.
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.dock
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.components.glass.GlassSurface
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.search_open_a11y
/**
* 44dp circular Liquid-glass button per UI-SPEC line 181.
*
* Visible only on Recipes + Pantry tabs (D-06 — gated by AppShell, not here).
* Hidden when search is open (also gated by AppShell — see plan 02.1-05 AppShell.kt).
*
* Substrate: [GlassSurface] cornerRadius=22dp = full-circle at 44dp.
* Icon: [Icons.Outlined.Search] tinted [RecipeTheme.colors.content].
* Accessibility: [contentDescription] = stringResource(search_open_a11y) per UI-SPEC line 221.
*/
@Composable
fun FloatingSearchButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val a11y = stringResource(Res.string.search_open_a11y)
GlassSurface(
modifier = modifier
.size(44.dp)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
.semantics { contentDescription = a11y },
cornerRadius = 22.dp,
) {
Box(
modifier = Modifier.size(44.dp),
contentAlignment = Alignment.Center,
) {
Image(
painter = rememberVectorPainter(image = Icons.Outlined.Search),
contentDescription = null,
colorFilter = ColorFilter.tint(RecipeTheme.colors.content),
modifier = Modifier.size(20.dp),
)
}
}
}
```
Implementation note 4: `search_open_a11y` resource key is also owned by plan 02.1-04.
This plan must only verify the key exists; do not edit strings.xml in plan 02.1-05.
Material 3 boundary: NEITHER file may import `androidx.compose.material3.*`. Use
`androidx.compose.material.icons.outlined.*` (icons-extended is fine — it's the
icon set artifact, not Material 3 components).
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- `grep -c 'fun DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns 1
- `grep -c 'animateContentSize' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'AnimatedContent' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'FastOutSlowInEasing' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'durationMillis = 250' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'role = Role.Tab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'selected = isActive' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c '28.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c '22.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c '56.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c '44.dp' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt` returns at least 1
- `grep -c 'fun FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1
- `grep -c 'GlassSurface' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1
- `grep -c 'Icons.Outlined.Search' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns 1
- `grep -c 'search_open_a11y' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt` returns at least 1
- Material 3 boundary in dock package: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0
- Direct Liquid / Haze imports forbidden in dock package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>DockBar renders 4-tab expanded form (icon + label) and collapses to a single circular toggle on the active tab; transition is one coordinated animateContentSize + AnimatedContent block at 250ms FastOutSlowInEasing. FloatingSearchButton is 44dp circular GlassSurface with the search icon. Both consume GlassSurface only — no direct Liquid/Haze imports.</done>
</task>
<task type="auto">
<name>Task 3: Create AppShell.kt — authenticated root composable hosting RootNavHost + bottom chrome overlay</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ShellViewModel.kt — just-created
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/DockBar.kt — just-created
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/FloatingSearchButton.kt — just-created
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/RootNavHost.kt — from plan 02.1-04
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — from plan 02.1-04
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Code Example 2 (lines 514-565) — verbatim AppShell skeleton
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Pitfall F (lines 471-473) — inset handling: navigationBars + ime, NOT safeContentPadding
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § AppShell.kt (lines 184-203)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area (lines 268-272)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt — VM observation pattern via koinViewModel + collectAsStateWithLifecycle
</read_first>
<action>
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.navigation.PantryGraph
import dev.ulfrx.recipe.navigation.PlannerGraph
import dev.ulfrx.recipe.navigation.RecipesGraph
import dev.ulfrx.recipe.navigation.RootNavHost
import dev.ulfrx.recipe.navigation.ShoppingGraph
import dev.ulfrx.recipe.navigation.navigateToTab
import dev.ulfrx.recipe.ui.components.dock.DockBar
import dev.ulfrx.recipe.ui.components.dock.FloatingSearchButton
import dev.ulfrx.recipe.ui.components.search.SearchPill
import dev.ulfrx.recipe.ui.components.glass.GlassBackdropSource
import dev.ulfrx.recipe.ui.screens.recipes.RecipesSearchViewModel
import dev.ulfrx.recipe.ui.screens.pantry.PantrySearchViewModel
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
/**
* Authenticated root composable per RESEARCH § Code Example 2 (lines 514-565).
*
* Layout responsibilities:
* - Background: full-screen [RecipeTheme.colors.background] under the safe area.
* - Body: [RootNavHost] consumes the full screen.
* - Bottom chrome (overlay): bottom-anchored Column containing optional [SearchPill]
* (when ui.searchOpen && active.hasSearch) and the [DockBar] (always visible).
* Chrome consumes [WindowInsets.navigationBars] explicitly — Pitfall F (RESEARCH
* lines 471-473): do NOT also use safeContentPadding() at this layer; tab body
* consumes top inset (status bars) inside each tab screen.
* - [FloatingSearchButton] aligned [Alignment.BottomEnd], visible only when
* !ui.searchOpen && active.hasSearch (D-06).
*
* Active-tab tracking: derived from the NavHost's current back stack entry's route.
* The shell's [ShellViewModel] mirrors active tab so chrome can react synchronously
* to tab switches even before NavHost navigation completes.
*/
@Composable
fun AppShell(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val backStack by navController.currentBackStackEntryAsState()
val activeTab = remember(backStack) {
backStack?.toBottomBarDestination() ?: BottomBarDestination.Default
}
val vm: ShellViewModel = koinViewModel()
val ui by vm.state.collectAsStateWithLifecycle()
val recipesSearchVm: RecipesSearchViewModel = koinViewModel()
val recipesSearch by recipesSearchVm.state.collectAsStateWithLifecycle()
val pantrySearchVm: PantrySearchViewModel = koinViewModel()
val pantrySearch by pantrySearchVm.state.collectAsStateWithLifecycle()
fun closeActiveSearch() {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.close()
BottomBarDestination.Pantry -> pantrySearchVm.close()
else -> Unit
}
vm.closeSearch()
}
// Sync ShellViewModel.activeTab with NavHost-derived activeTab for
// back-button + deep-link cases (NavHost is the source of truth on tab change
// when navigation goes through navigateToTab; this sync handles all other paths).
if (ui.activeTab != activeTab) {
// Idempotent — onTabChanged also clears any open search per D-08.
vm.onTabChanged(activeTab)
}
Box(
modifier = modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
// Body — RootNavHost fills the available space and is the shared source layer
// for Liquid/Haze chrome sampling via GlassBackdropSource (plan 02.1-03).
GlassBackdropSource(modifier = Modifier.fillMaxSize()) {
RootNavHost(
navController = navController,
modifier = Modifier.fillMaxSize(),
)
}
// Bottom chrome overlay — Column anchored to bottom-center.
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.imePadding() // UI-SPEC line 271 — search pill rides above keyboard
.padding(bottom = RecipeTheme.spacing.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(RecipeTheme.spacing.sm),
) {
if (ui.searchOpen && activeTab.hasSearch) {
val placeholderRes = activeTab.searchPlaceholder
if (placeholderRes != null) {
val activeSearch = when (activeTab) {
BottomBarDestination.Recipes -> recipesSearch
BottomBarDestination.Pantry -> pantrySearch
else -> null
}
val activeSearchVm = when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm
BottomBarDestination.Pantry -> pantrySearchVm
else -> null
}
SearchPill(
query = activeSearch?.query.orEmpty(),
onQueryChange = { activeSearchVm?.onQueryChange(it) },
onClear = { activeSearchVm?.clear() },
onClose = { closeActiveSearch() },
placeholder = stringResource(placeholderRes),
)
}
}
DockBar(
destinations = BottomBarDestination.entries,
active = activeTab,
collapsed = ui.searchOpen,
onTabSelect = { dest ->
navController.navigateToTab(dest.graphRoute)
vm.onTabChanged(dest)
},
onCollapsedTap = { closeActiveSearch() },
)
}
// Floating search button — adjacent to dock per D-06, visible only on
// tabs that have search and only when search is closed.
if (!ui.searchOpen && activeTab.hasSearch) {
FloatingSearchButton(
onClick = {
when (activeTab) {
BottomBarDestination.Recipes -> recipesSearchVm.open()
BottomBarDestination.Pantry -> pantrySearchVm.open()
else -> Unit
}
vm.openSearch()
},
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm),
)
}
}
}
/**
* Maps a [androidx.navigation.NavBackStackEntry]'s current route hierarchy to a
* [BottomBarDestination]. Reads the *parent graph* route on the back stack, since
* each tab is a nested graph.
*/
private fun androidx.navigation.NavBackStackEntry?.toBottomBarDestination(): BottomBarDestination? {
if (this == null) return null
// Inspect the destination hierarchy for the parent graph route.
// CMP nav-compose 2.9.2: NavDestination.hierarchy yields parent-to-child sequence.
val hierarchy = destination.hierarchy
return when {
hierarchy.any { it.hasRoute(PlannerGraph::class) } -> BottomBarDestination.Planner
hierarchy.any { it.hasRoute(RecipesGraph::class) } -> BottomBarDestination.Recipes
hierarchy.any { it.hasRoute(PantryGraph::class) } -> BottomBarDestination.Pantry
hierarchy.any { it.hasRoute(ShoppingGraph::class) } -> BottomBarDestination.Shopping
else -> null
}
}
```
The `hasRoute(PlannerGraph::class)` API is the type-safe destination matcher in
nav-compose 2.9.x. If the precise extension is unavailable, fall back to comparing
`destination.route` strings (the string-form route is the FQN of the @Serializable
type).
Required imports for the helper at the bottom:
```kotlin
import androidx.navigation.NavBackStackEntry // for receiver type
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
```
Implementation note: this plan depends on 02.1-06, so `SearchPill`,
`RecipesSearchViewModel`, and `PantrySearchViewModel` already exist before AppShell
compiles. Do not create local stubs.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- `grep -c 'fun AppShell' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- `grep -c 'rememberNavController' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- `grep -c 'RootNavHost' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'koinViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'collectAsStateWithLifecycle' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 3
- `grep -c 'DockBar' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'FloatingSearchButton' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'SearchPill' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'GlassBackdropSource' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'RecipesSearchViewModel\\|PantrySearchViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 2
- `grep -c 'activeSearchVm?.onQueryChange' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- `grep -c 'fun closeActiveSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- `grep -c 'onCollapsedTap = { closeActiveSearch() }' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- `grep -c 'WindowInsets.navigationBars' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'imePadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'safeContentPadding' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0 (Pitfall F — must NOT use safeContentPadding here)
- `grep -c 'navigateToTab' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- `grep -c 'collapsed = ui.searchOpen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 1
- Conditional render of FloatingSearchButton: `grep -c '!ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- Conditional render of SearchPill: `grep -c 'ui.searchOpen && activeTab.hasSearch' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns at least 1
- Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShell.kt` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>
AppShell hosts RootNavHost as body inside GlassBackdropSource + DockBar / FloatingSearchButton / SearchPill as bottom chrome overlay; consumes navigationBars + ime insets explicitly per Pitfall F; renders FloatingSearchButton only on tabs where activeTab.hasSearch is true and searchOpen is false; SearchPill reads/writes the active tab SearchViewModel.
</done>
</task>
</tasks>
<verification>
- iOS K/N compile green (after prerequisite plans 02.1-03 + 02.1-04 + 02.1-06 have landed):
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- Material 3 boundary preserved across all 4 new files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/` returns 0
- Liquid / Haze imports confined to glass package: `grep -rE '(io\.github\.fletchmckee\.liquid|dev\.chrisbanes\.haze)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/dock/ | wc -l` returns 0
- ShellViewModel state machine semantics: closeSearch hides the search surface; AppShell delegates close/clear/query changes to the active tab SearchViewModel; onTabChanged resets shell search visibility on tab switch.
- AppShell uses navigationBars + ime padding explicitly; safeContentPadding() is NOT used at AppShell layer.
- V-09 + V-11 manual smoke prerequisites in place: dock collapse animation can be observed; Liquid backend renders chrome (when build resolves Liquid for the target).
</verification>
<success_criteria>
1. ShellViewModel mirrors LoginViewModel's StateFlow + method-per-action shape with 3 shell actions: openSearch / closeSearch / onTabChanged. Query state lives in RecipesSearchViewModel / PantrySearchViewModel from plan 02.1-06.
2. DockBar renders 4 tabs (icon + label always — D-02) when expanded, collapses to single circular icon-only toggle on the active tab when search opens (D-05). Single coordinated animation: animateContentSize + AnimatedContent at 250ms FastOutSlowInEasing. Each tab cell has Role.Tab + selected + contentDescription (UI-SPEC line 220).
3. FloatingSearchButton is a 44dp circular GlassSurface(cornerRadius = 22.dp) with Icons.Outlined.Search and search_open_a11y description.
4. AppShell hosts RootNavHost inside GlassBackdropSource (body) + DockBar (always-present chrome) + FloatingSearchButton (visible only when !searchOpen && activeTab.hasSearch) + SearchPill (rendered conditionally and wired to the active tab SearchViewModel from plan 02.1-06).
5. AppShell consumes WindowInsets.navigationBars + imePadding() explicitly; safeContentPadding() is NOT used (Pitfall F).
6. Direct Liquid / Haze imports zero in the shell + dock packages — chrome consumes GlassSurface only.
7. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 4 new files.
</success_criteria>
<output>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-05-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
- Whether AppShell's active-tab SearchViewModel wiring covered both Recipes and Pantry paths in the final implementation.
- Whether `Compose Unstyled TabGroup` API was used in DockBar or the Row + semantics fallback.
- Whether `hasRoute(*Graph::class)` worked or the string-route comparison was needed for the activeTab derivation in AppShell.
- Final touch-target measurements for the dock cells (≥ 44dp confirmed by visual inspection in iOS sim during 02.1-08's manual smoke).
</output>