Implement main app navigation

This commit is contained in:
2026-05-08 14:03:26 +02:00
parent f7e866a08d
commit 794e27c554
90 changed files with 11725 additions and 187 deletions

View File

@@ -0,0 +1,802 @@
---
phase: 02.1
plan: 07
type: execute
wave: 3
depends_on: ["02.1-02", "02.1-04"]
files_modified:
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt
- composeApp/src/commonMain/composeResources/values/strings.xml
autonomous: true
requirements: [UI-09]
tags: [kotlin, compose-multiplatform, empty-state, viewmodel, theme-tokens, accessibility, i18n, polish-copy]
must_haves:
truths:
- "EmptyState composable signature is exactly: EmptyState(icon: ImageVector, title: String, subtitle: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null) per D-13 / UI-SPEC line 183"
- "EmptyState wraps its column in Modifier.semantics(mergeDescendants = true) per UI-SPEC line 226 — single VoiceOver announce"
- "EmptyState renders icon (48dp, contentMuted), Spacer(sm), title (display), Spacer(lg), subtitle (body, contentMuted), with optional action below at xl spacing"
- "Each tab Screen renders Box(fillMaxSize, background = RecipeTheme.colors.background) with inline title (RecipeTheme.typography.title) at top + EmptyState centered below"
- "Each tab ViewModel exposes state: StateFlow<{Tab}State> with no actions this phase (screens are empty-state-only)"
- "All 8 new empty-state strings.xml keys present: empty_planner_title, empty_planner_subtitle, empty_recipes_title, empty_recipes_subtitle, empty_pantry_title, empty_pantry_subtitle, empty_shopping_title, empty_shopping_subtitle; shared tab/search chrome keys already exist from plan 02.1-04"
- "Polish copy is verbatim from UI-SPEC § Copywriting Contract lines 121-158"
- "Zero hardcoded Polish literals in any *.kt file touched by this plan — all strings via stringResource(Res.string.*)"
- "Zero `androidx.compose.material3` imports in any new file"
artifacts:
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt"
provides: "Reusable EmptyState(icon, title, subtitle, action?) composable"
contains: "fun EmptyState"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt"
provides: "PlannerScreen — inline title + EmptyState"
contains: "fun PlannerScreen"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt"
provides: "PlannerViewModel — empty StateFlow per phase scope"
contains: "class PlannerViewModel"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt"
provides: "RecipesScreen — inline title + EmptyState"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt"
provides: "RecipesViewModel"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt"
provides: "PantryScreen — inline title + EmptyState"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt"
provides: "PantryViewModel"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt"
provides: "ShoppingScreen — inline title + EmptyState"
- path: "composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt"
provides: "ShoppingViewModel"
- path: "composeApp/src/commonMain/composeResources/values/strings.xml"
provides: "8 empty-state keys; shared tab/search chrome keys are owned by plan 02.1-04"
contains: "empty_planner_title"
key_links:
- from: "ui/screens/planner/PlannerScreen.kt"
to: "ui/components/empty/EmptyState.kt + navigation/BottomBarDestination.kt"
via: "EmptyState(icon = BottomBarDestination.Planner.icon, title = stringResource(Res.string.empty_planner_title), subtitle = stringResource(Res.string.empty_planner_subtitle))"
pattern: "EmptyState"
- from: "ui/screens/recipes/RecipesScreen.kt"
to: "ui/components/empty/EmptyState.kt"
via: "same EmptyState pattern with empty_recipes_*"
pattern: "empty_recipes"
- from: "ui/screens/pantry/PantryScreen.kt"
to: "ui/components/empty/EmptyState.kt"
via: "same EmptyState pattern with empty_pantry_*"
pattern: "empty_pantry"
- from: "ui/screens/shopping/ShoppingScreen.kt"
to: "ui/components/empty/EmptyState.kt"
via: "same EmptyState pattern with empty_shopping_*"
pattern: "empty_shopping"
---
<objective>
Build the user-visible content of every tab — the reusable `EmptyState` composable (D-13 + UI-SPEC line 183), four tab screens (PlannerScreen, RecipesScreen, PantryScreen, ShoppingScreen) each rendering an inline tab title + centered EmptyState, four tab ViewModels following the StateFlow + method-per-action pattern (no actions this phase since screens are empty-state-only), and the strings.xml resource extension with the 8 empty-state keys. The shared tab labels, search placeholders, and search a11y keys are owned by plan 02.1-04 so wave 3 has no parallel search-resource ownership.
This plan delivers UI-09 (anticipatory empty states with calm Polish copy on every tab — D-10/D-11/D-12). It depends on plan 02.1-02 (theme tokens) and 02.1-04 (BottomBarDestination + shared shell/search resource keys) — every tab screen reads `RecipeTheme.colors.background`, `RecipeTheme.typography.title`, `RecipeTheme.spacing.lg/xl`, plus the EmptyState component.
Plan 02.1-08 (Wave 5) wires the four tab screens into RootNavHost (replacing the TabHomePlaceholder stubs from plan 02.1-04) and registers all four tab VMs in ShellModule.
Per CONTEXT D-12 there are NO CTAs in empty states this phase — the `action` slot on EmptyState is reserved unused. Per CONTEXT D-04 there is no top app bar — each screen renders its tab title inline at the top of its body.
Purpose: UI-09 hard-coded — anticipatory empty states with calm Polish copy on every tab.
Output: 9 new commonMain files (1 EmptyState + 4 screens + 4 VMs); strings.xml extended with 8 empty-state keys.
</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/PostLoginPlaceholderScreen.kt
@composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt
@composeApp/src/commonMain/composeResources/values/strings.xml
<interfaces>
After plan 02.1-02 lands:
```kotlin
// dev.ulfrx.recipe.ui.theme
object RecipeTheme {
val colors: RecipeColors @Composable @ReadOnlyComposable get() // .background, .content, .contentMuted, .surfaceGlass, ...
val typography: RecipeTypography @Composable @ReadOnlyComposable get() // .display, .title, .body, .label
val spacing: RecipeSpacing @Composable @ReadOnlyComposable get() // .xs, .sm, .lg, .xl, then "2xl" / "3xl" — verify exact identifier names from RecipeSpacing.kt (likely .xxl / .xxxl since identifiers can't start with digits)
}
```
After plan 02.1-04 lands (if Wave-1 ordering is preserved):
```kotlin
// dev.ulfrx.recipe.navigation
enum class BottomBarDestination {
Planner(graphRoute = PlannerGraph, labelRes = ..., icon = Icons.Outlined.CalendarMonth, ...),
Recipes(... icon = Icons.Outlined.MenuBook ...),
Pantry(... icon = Icons.Outlined.Inventory2 ...),
Shopping(... icon = Icons.Outlined.ShoppingCart ...),
}
```
This plan reads `BottomBarDestination.Planner.icon` etc. as the EmptyState icon parameter — keeps icon mapping in one place.
LoginViewModel pattern (analog from `LoginViewModel.kt:37-55`) — mirror this shape for empty VMs:
```kotlin
class XxxViewModel : ViewModel() {
private val _state = MutableStateFlow(XxxState())
val state: StateFlow<XxxState> = _state.asStateFlow()
// No actions this phase.
}
```
PostLoginPlaceholderScreen (analog from `PostLoginPlaceholderScreen.kt:32-62`) — mirror the Box scaffolding shape but rebuild on RecipeTheme tokens (NO Material 3) per PATTERNS § Tab screens lines 206-238.
Existing strings.xml (after plan 02.1-04 lands):
- auth_* (preserved)
- shell_tab_planner / shell_tab_recipes / shell_tab_pantry / shell_tab_shopping (added by 02.1-04)
- search_placeholder_recipes / search_placeholder_pantry (added by 02.1-04)
- search_open_a11y / search_close_a11y / search_clear_a11y (added by 02.1-04)
This plan adds:
- empty_planner_title / empty_planner_subtitle
- empty_recipes_title / empty_recipes_subtitle
- empty_pantry_title / empty_pantry_subtitle
- empty_shopping_title / empty_shopping_subtitle
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Extend strings.xml with empty-state copy and verify shared search keys</name>
<files>composeApp/src/commonMain/composeResources/values/strings.xml</files>
<read_first>
- composeApp/src/commonMain/composeResources/values/strings.xml — current state (preserve all existing keys)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Copywriting Contract (lines 121-158) — verbatim Polish copy + key names
</read_first>
<action>
Open `composeApp/src/commonMain/composeResources/values/strings.xml`. Locate the closing `</resources>` tag.
For each empty-state key below, run `grep -c '<string name="KEY"' strings.xml`. If the count is 0, INSERT the key just before `</resources>`. If the count is > 0, SKIP. Do not add search a11y keys here; they are owned by plan 02.1-04 and this task only verifies they remain present.
Keys to add (Polish copy is verbatim from UI-SPEC § Copywriting Contract):
```xml
<!-- Phase 2.1 — Empty-state copy (UI-09, CONTEXT D-10/D-11/D-12) -->
<string name="empty_planner_title">Twój plan tygodnia czeka</string>
<string name="empty_planner_subtitle">Wkrótce zobaczysz tu zaplanowane posiłki.</string>
<string name="empty_recipes_title">Tu pojawi się Twoja książka kucharska</string>
<string name="empty_recipes_subtitle">Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu.</string>
<string name="empty_pantry_title">Spiżarnia jest jeszcze pusta</string>
<string name="empty_pantry_subtitle">Wkrótce zobaczysz tu wszystko, co masz pod ręką.</string>
<string name="empty_shopping_title">Lista zakupów czeka na Twój plan</string>
<string name="empty_shopping_subtitle">Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje.</string>
```
Polish-character verification: every quoted value must have its diacritics rendered
correctly when the Compose Resources generator emits the bindings. UTF-8 encoding is
already the file standard (declared in the XML prolog from the existing file). Do
NOT manually escape `ą`, `ć`, `ę`, `ł`, `ń`, `ó`, `ś`, `ź`, `ż` — UTF-8 handles them.
Final validation:
```bash
grep -c '<string name=' composeApp/src/commonMain/composeResources/values/strings.xml
```
The total key count should be:
- 7 auth_* (pre-existing)
- 4 shell_tab_* + 2 search_placeholder_* (from plan 02.1-04)
- 8 empty_* (this plan)
- 3 search_*_a11y (from plan 02.1-04)
= at minimum 22, at most 24 depending on which plan committed which a11y keys first.
The exact count varies based on execution ordering of 02.1-06 vs 02.1-07. Either is
fine. The key VERIFICATION is: every key name listed above is present exactly once.
</action>
<verify>
<automated>./gradlew :composeApp:generateComposeResClass -q && count="$(find composeApp/build/generated/compose -name '*.kt' -path '*generated/resources*' -exec grep -l 'empty_planner_title\|empty_recipes_title\|empty_pantry_title\|empty_shopping_title' {} \; | wc -l | tr -d ' ')"; test "$count" -ge 1</automated>
</verify>
<acceptance_criteria>
- All 8 empty-state keys present exactly once: `for k in empty_planner_title empty_planner_subtitle empty_recipes_title empty_recipes_subtitle empty_pantry_title empty_pantry_subtitle empty_shopping_title empty_shopping_subtitle; do test "$(grep -c "<string name=\"$k\"" composeApp/src/commonMain/composeResources/values/strings.xml)" = "1" || exit 1; done`
- All 3 search a11y keys from plan 02.1-04 are still present exactly once: `for k in search_open_a11y search_close_a11y search_clear_a11y; do test "$(grep -c "<string name=\"$k\"" composeApp/src/commonMain/composeResources/values/strings.xml)" = "1" || exit 1; done`
- All 7 pre-existing auth_* keys preserved: `grep -c '<string name="auth_' composeApp/src/commonMain/composeResources/values/strings.xml` returns at least 7
- All 9 plan 02.1-04 keys preserved: `for k in shell_tab_planner shell_tab_recipes shell_tab_pantry shell_tab_shopping search_placeholder_recipes search_placeholder_pantry search_open_a11y search_close_a11y search_clear_a11y; do test "$(grep -c "<string name=\"$k\"" composeApp/src/commonMain/composeResources/values/strings.xml)" = "1" || exit 1; done`
- Polish copy verbatim from UI-SPEC: `grep -c 'Twój plan tygodnia czeka' composeApp/src/commonMain/composeResources/values/strings.xml` returns 1
- `grep -c 'Wkrótce zobaczysz tu zaplanowane posiłki.' composeApp/src/commonMain/composeResources/values/strings.xml` returns 1
- `grep -c 'Spiżarnia jest jeszcze pusta' composeApp/src/commonMain/composeResources/values/strings.xml` returns 1
- Compose Resources class generation succeeds: `./gradlew :composeApp:generateComposeResClass -q` exits 0
</acceptance_criteria>
<done>strings.xml carries 8 empty-state keys with verbatim Polish copy from UI-SPEC; shared search a11y keys from plan 02.1-04 remain present exactly once. All pre-existing keys preserved. Compose Resources `Res.string.*` bindings regenerate successfully.</done>
</task>
<task type="auto">
<name>Task 2: Create EmptyState.kt — the reusable empty-state composable per D-13 / UI-SPEC line 183</name>
<files>composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginScreen.kt (lines 48-92) — column skeleton + center alignment analog
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md § Code Example 3 (lines 568-606) — verbatim implementation shape
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 183 — signature contract
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Accessibility line 226 — Modifier.semantics(mergeDescendants = true)
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md D-10 / D-11 / D-12 / D-13 — visual treatment + tone + no CTA + reusable component
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § EmptyState (lines 243-264)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/theme/RecipeTheme.kt — verify exact spacing accessor names (xs/sm/lg/xl/xxl/xxxl per Kotlin naming)
</read_first>
<action>
Create `composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt`:
```kotlin
package dev.ulfrx.recipe.ui.components.empty
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.ui.theme.RecipeTheme
/**
* Reusable empty-state composable per CONTEXT D-13 / UI-SPEC line 183.
*
* Visual contract (UI-SPEC line 183 + RESEARCH § Code Example 3):
* - Centered Column on the screen.
* - 48dp icon tinted [RecipeTheme.colors.contentMuted] (calm, low-saturation per D-10).
* - 8dp gap (`sm`) between icon and headline.
* - Headline in [RecipeTheme.typography.display] color [RecipeTheme.colors.content].
* - 16dp gap (`lg`) between headline and subline.
* - Subline in [RecipeTheme.typography.body] color [RecipeTheme.colors.contentMuted].
* - Optional [action] slot below subline at 24dp gap (`xl`); unused this phase
* (D-12 — no CTAs in empty states this phase, but the slot is reserved per
* D-13 so feature phases can add CTAs without a new component).
*
* Accessibility (UI-SPEC line 226): the column carries
* `Modifier.semantics(mergeDescendants = true)` so VoiceOver reads the headline
* + subline as one announcement, not two — calmer screen-reader experience.
*
* The horizontal inset is owned by [EmptyState] itself: 24dp (`xl`) per UI-SPEC
* line 183. Screen-level safe-area insets are owned by the calling screen, not
* here.
*/
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = RecipeTheme.spacing.xl)
.semantics(mergeDescendants = true) {},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
painter = rememberVectorPainter(image = icon),
contentDescription = null,
colorFilter = ColorFilter.tint(RecipeTheme.colors.contentMuted),
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(RecipeTheme.spacing.sm))
BasicText(
text = title,
style = RecipeTheme.typography.display.copy(
color = RecipeTheme.colors.content,
textAlign = TextAlign.Center,
),
)
Spacer(Modifier.height(RecipeTheme.spacing.lg))
BasicText(
text = subtitle,
style = RecipeTheme.typography.body.copy(
color = RecipeTheme.colors.contentMuted,
textAlign = TextAlign.Center,
),
)
if (action != null) {
Spacer(Modifier.height(RecipeTheme.spacing.xl))
action()
}
}
}
```
Note on `BasicText` vs `Text`: `BasicText` ships with `compose-foundation` and is
Material-free — keeps this composable usable from any new shell-side code without
pulling in Material 3 (CLAUDE.md / UI-SPEC line 31). The previous PostLoginPlaceholderScreen
used `androidx.compose.material3.Text`; this is intentionally NOT mirrored in shell code.
Note on spacing accessor names: `RecipeTheme.spacing.xl` is fine (`xl` is a valid
Kotlin identifier). The UI-SPEC names `2xl` / `3xl` (lines 36-46) cannot be Kotlin
identifiers as-is, so plan 02.1-02 should have remapped them to `xxl` / `xxxl` (or
backticked them). Verify the actual accessor names in RecipeTheme.spacing.kt before
using them. This plan's EmptyState only uses `sm`, `lg`, `xl` — all valid plain
identifiers — so no risk of breakage even if the higher accessors are backticked.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- `grep -c 'fun EmptyState' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- Signature exact: `grep -c 'icon: ImageVector' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- `grep -c 'title: String' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- `grep -c 'subtitle: String' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- `grep -c 'action: (@Composable () -> Unit)? = null' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- mergeDescendants for VoiceOver: `grep -c 'mergeDescendants = true' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- 48dp icon: `grep -c 'size(48.dp)' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 1
- Theme tokens used: `grep -c 'RecipeTheme.colors.contentMuted\|RecipeTheme.colors.content\|RecipeTheme.typography.display\|RecipeTheme.typography.body' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns at least 4
- Material 3 boundary: `grep -c 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>EmptyState ships with the locked D-13 signature, the spacing rhythm from UI-SPEC line 183, and the VoiceOver-friendly mergeDescendants semantics. Material 3 zero imports.</done>
</task>
<task type="auto">
<name>Task 3: Create 4 tab ViewModels — pure StateFlow with no actions this phase</name>
<files>
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt
</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/LoginViewModel.kt — analog VM shape
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § ShellViewModel + § Tab ViewModels
</read_first>
<action>
Create 4 minimal ViewModels — each with empty `*State` data class + `state: StateFlow<*State>` and zero actions (Phase 5+ adds the actions).
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.planner
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PlannerScreen]. Phase 2.1 ships only the empty state, so the
* VM has no fields beyond a marker for future expansion. Phase 6 (Meal Planner —
* Core Write Path) extends this with calendar data + actions.
*/
data class PlannerState(val isEmpty: Boolean = true)
class PlannerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlannerState())
val state: StateFlow<PlannerState> = _state.asStateFlow()
}
```
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.recipes
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [RecipesScreen]. Phase 2.1 ships only the empty state. Phase 5
* (Recipe Catalog Read Path) extends this with `recipes: List<RecipeCard>` etc.
*/
data class RecipesState(val isEmpty: Boolean = true)
class RecipesViewModel : ViewModel() {
private val _state = MutableStateFlow(RecipesState())
val state: StateFlow<RecipesState> = _state.asStateFlow()
}
```
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.pantry
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [PantryScreen]. Phase 2.1 ships only the empty state. Phase 8
* (Pantry) extends this with inventory rows + actions.
*/
data class PantryState(val isEmpty: Boolean = true)
class PantryViewModel : ViewModel() {
private val _state = MutableStateFlow(PantryState())
val state: StateFlow<PantryState> = _state.asStateFlow()
}
```
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.shopping
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* UI state for [ShoppingScreen]. Phase 2.1 ships only the empty state. Phase 9
* (Shopping List & Session Log) extends this with list items + session actions.
*/
data class ShoppingState(val isEmpty: Boolean = true)
class ShoppingViewModel : ViewModel() {
private val _state = MutableStateFlow(ShoppingState())
val state: StateFlow<ShoppingState> = _state.asStateFlow()
}
```
All four follow the LoginViewModel shape exactly: ViewModel base class, private
MutableStateFlow, public read-only StateFlow, no actions.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- All 4 VM classes declared: `grep -c 'class PlannerViewModel\|class RecipesViewModel\|class PantryViewModel\|class ShoppingViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt` returns 4
- Each VM extends ViewModel: `grep -lc ': ViewModel()' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt | wc -l` returns 4
- Each VM exposes state: StateFlow<*>: each file has `val state: StateFlow<` (verify with `grep -c 'val state: StateFlow' <file>` returns 1 per file)
- No actions on tab VMs (zero `fun ` declarations beyond the optional getter): `grep -c '^ fun ' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt` returns 0
- Material 3 boundary: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt` returns 0
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>Four pure-state ViewModels follow the LoginViewModel shape; each exposes a StateFlow with a marker `isEmpty: Boolean = true` field for future-phase expansion; no actions defined.</done>
</task>
<task type="auto">
<name>Task 4: Create 4 tab Screens — inline title + EmptyState centered, all reading RecipeTheme tokens</name>
<files>
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt,
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt
</files>
<read_first>
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt — just-created
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/auth/PostLoginPlaceholderScreen.kt — analog (rebuild on RecipeTheme, not Material 3)
- composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/navigation/BottomBarDestination.kt — for icon mapping
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Component Inventory line 184 — screen scaffold contract
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md § Layout & Safe Area lines 268-272 — top inset (statusBars), no top app bar
- .planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md § Tab screens (lines 206-238)
</read_first>
<action>
Each tab screen has the same shape:
- `Box(Modifier.fillMaxSize().background(RecipeTheme.colors.background))`
- Top: status bar inset + `xl` (24dp) padding + inline title `RecipeTheme.typography.title`
- Bottom: centered EmptyState (icon = BottomBarDestination.<TabName>.icon)
- Bottom inset for the chrome overlay (DockBar + SearchPill + FloatingSearchButton)
is consumed by AppShell — NOT by individual screens. Each screen just lays out
in the available area; the chrome floats on top.
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.planner
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_planner_subtitle
import recipe.composeapp.generated.resources.empty_planner_title
import recipe.composeapp.generated.resources.shell_tab_planner
/**
* Phase 2.1 — empty-state screen for the Planner tab. Phase 6 replaces the
* empty body with the calendar grid.
*
* Layout:
* - Background: [RecipeTheme.colors.background] under the safe area.
* - Top: status bar inset + `xl` (24dp) top padding + inline title in `title` style.
* - Body: centered [EmptyState] with calm Polish copy from `empty_planner_*`
* string resources. No CTA (D-12).
*
* The bottom safe-area inset is consumed by AppShell's chrome overlay (plan 02.1-05),
* NOT by this screen — the screen renders edge-to-edge under the floating dock.
*/
@Composable
fun PlannerScreen(viewModel: PlannerViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxSize()
.background(RecipeTheme.colors.background),
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_planner),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Planner.icon,
title = stringResource(Res.string.empty_planner_title),
subtitle = stringResource(Res.string.empty_planner_subtitle),
)
}
}
}
}
```
Create the other three screens by analogy — change the package, the VM type, the
BottomBarDestination entry, and the resource keys (empty_recipes_*, empty_pantry_*,
empty_shopping_* + shell_tab_recipes / pantry / shopping):
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.recipes
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_recipes_subtitle
import recipe.composeapp.generated.resources.empty_recipes_title
import recipe.composeapp.generated.resources.shell_tab_recipes
@Composable
fun RecipesScreen(viewModel: RecipesViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_recipes),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Recipes.icon,
title = stringResource(Res.string.empty_recipes_title),
subtitle = stringResource(Res.string.empty_recipes_subtitle),
)
}
}
}
}
```
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.pantry
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_pantry_subtitle
import recipe.composeapp.generated.resources.empty_pantry_title
import recipe.composeapp.generated.resources.shell_tab_pantry
@Composable
fun PantryScreen(viewModel: PantryViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_pantry),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Pantry.icon,
title = stringResource(Res.string.empty_pantry_title),
subtitle = stringResource(Res.string.empty_pantry_subtitle),
)
}
}
}
}
```
`composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt`:
```kotlin
package dev.ulfrx.recipe.ui.screens.shopping
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.ulfrx.recipe.navigation.BottomBarDestination
import dev.ulfrx.recipe.ui.components.empty.EmptyState
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
import recipe.composeapp.generated.resources.empty_shopping_subtitle
import recipe.composeapp.generated.resources.empty_shopping_title
import recipe.composeapp.generated.resources.shell_tab_shopping
@Composable
fun ShoppingScreen(viewModel: ShoppingViewModel) {
@Suppress("UNUSED_VARIABLE")
val state by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize().background(RecipeTheme.colors.background),
) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = RecipeTheme.spacing.xl),
verticalArrangement = Arrangement.Top,
) {
BasicText(
text = stringResource(Res.string.shell_tab_shopping),
style = RecipeTheme.typography.title.copy(color = RecipeTheme.colors.content),
modifier = Modifier.padding(horizontal = RecipeTheme.spacing.lg),
)
Box(modifier = Modifier.fillMaxSize()) {
EmptyState(
icon = BottomBarDestination.Shopping.icon,
title = stringResource(Res.string.empty_shopping_title),
subtitle = stringResource(Res.string.empty_shopping_subtitle),
)
}
}
}
}
```
All four screens have identical structure differing only in: VM type, package,
BottomBarDestination entry, and 3 resource keys. This is intentional — D-13's reusable
EmptyState carries all the visual logic; tab screens are thin scaffolds.
Material 3 boundary: NONE of the four screens may import `androidx.compose.material3.*`.
`androidx.compose.foundation.text.BasicText` replaces the legacy `Text`.
`androidx.compose.foundation.background` replaces `Surface(color = ...)`.
</action>
<verify>
<automated>./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q</automated>
</verify>
<acceptance_criteria>
- All 4 screen functions declared: `grep -c 'fun PlannerScreen\|fun RecipesScreen\|fun PantryScreen\|fun ShoppingScreen' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt` returns 4
- Each screen takes its VM as parameter: `grep -c 'viewModel: PlannerViewModel\|viewModel: RecipesViewModel\|viewModel: PantryViewModel\|viewModel: ShoppingViewModel' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt` returns 4
- All 4 screens consume EmptyState: `grep -c 'EmptyState(' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt` returns 4
- All 4 use RecipeTheme tokens: `grep -lc 'RecipeTheme.colors.background' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt | wc -l` returns 4
- Each tab pulls its tab-specific empty resource keys: `grep -c 'empty_planner_' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt` returns at least 2; same for recipes/pantry/shopping in their respective files.
- Material 3 boundary across all 4 screens: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt` returns 0
- No hardcoded Polish literals in screens: `grep -E 'Text\("[A-Za-złąćęńóśźżĄĆĘŁŃÓŚŹŻ]' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt | wc -l` returns 0 (every string goes through stringResource)
- `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
</acceptance_criteria>
<done>Four tab screens exist; each renders a Box with RecipeTheme background, an inline tab title in `title` typography style, and a centered EmptyState reading the tab-specific empty_*_title / empty_*_subtitle resource keys. Material 3 zero imports; no hardcoded Polish literals.</done>
</task>
</tasks>
<verification>
- iOS K/N compile green: `./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q` exits 0
- iOS framework links: `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 -q` exits 0
- Compose Resources class regenerates: `./gradlew :composeApp:generateComposeResClass -q` exits 0
- Polish copy in strings.xml verbatim from UI-SPEC: `grep -c 'Wkrótce\|jest jeszcze pusta\|czeka na' composeApp/src/commonMain/composeResources/values/strings.xml` returns at least 4
- Material 3 boundary preserved across all 9 new files: `grep -rc 'androidx.compose.material3' composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/ composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/` returns 0
</verification>
<success_criteria>
1. EmptyState.kt declares the locked D-13 signature `EmptyState(icon, title, subtitle, modifier, action)` with mergeDescendants semantics for VoiceOver.
2. Four tab Screens exist (PlannerScreen, RecipesScreen, PantryScreen, ShoppingScreen); each renders Box(RecipeTheme.colors.background) + inline tab title (typography.title) + centered EmptyState with tab-specific icon and copy.
3. Four tab ViewModels exist (PlannerViewModel, RecipesViewModel, PantryViewModel, ShoppingViewModel); each exposes a marker StateFlow with no actions.
4. strings.xml carries 8 empty-state keys with verbatim Polish copy from UI-SPEC § Copywriting Contract; shared search a11y keys from plan 02.1-04 remain present exactly once; all pre-existing keys preserved.
5. UI-09 anchor: anticipatory empty states with calm Polish copy on every tab; no CTAs (D-12); icon + headline + subline visual treatment (D-10); single VoiceOver announcement (UI-SPEC line 226).
6. CLAUDE.md non-negotiable #9 honored: zero hardcoded Polish literals in any *.kt file; all strings via stringResource(Res.string.*).
7. Material 3 boundary preserved: zero `androidx.compose.material3` imports in any of the 9 new files.
</success_criteria>
<output>
After completion, create `.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-07-SUMMARY.md` per `$HOME/.claude/get-shit-done/templates/summary.md`. Record:
- Final spacing accessor names verified from `RecipeTheme.spacing` (likely `xxl` / `xxxl` for the 32dp / 48dp tokens, since Kotlin identifiers cannot start with a digit).
- Whether the search a11y keys (`search_open_a11y` / `search_close_a11y` / `search_clear_a11y`) were present exactly once from plan 02.1-04.
- Total strings.xml key count after this plan executes (should be at minimum 22, at most 24).
</output>