Files

49 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
02.1 07 execute 3
02.1-02
02.1-04
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
true
UI-09
kotlin
compose-multiplatform
empty-state
viewmodel
theme-tokens
accessibility
i18n
polish-copy
truths artifacts key_links
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
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt Reusable EmptyState(icon, title, subtitle, action?) composable fun EmptyState
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerScreen.kt PlannerScreen — inline title + EmptyState fun PlannerScreen
path provides contains
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/planner/PlannerViewModel.kt PlannerViewModel — empty StateFlow per phase scope class PlannerViewModel
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesScreen.kt RecipesScreen — inline title + EmptyState
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesViewModel.kt RecipesViewModel
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryScreen.kt PantryScreen — inline title + EmptyState
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantryViewModel.kt PantryViewModel
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingScreen.kt ShoppingScreen — inline title + EmptyState
path provides
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shopping/ShoppingViewModel.kt ShoppingViewModel
path provides contains
composeApp/src/commonMain/composeResources/values/strings.xml 8 empty-state keys; shared tab/search chrome keys are owned by plan 02.1-04 empty_planner_title
from to via pattern
ui/screens/planner/PlannerScreen.kt ui/components/empty/EmptyState.kt + navigation/BottomBarDestination.kt EmptyState(icon = BottomBarDestination.Planner.icon, title = stringResource(Res.string.empty_planner_title), subtitle = stringResource(Res.string.empty_planner_subtitle)) EmptyState
from to via pattern
ui/screens/recipes/RecipesScreen.kt ui/components/empty/EmptyState.kt same EmptyState pattern with empty_recipes_* empty_recipes
from to via pattern
ui/screens/pantry/PantryScreen.kt ui/components/empty/EmptyState.kt same EmptyState pattern with empty_pantry_* empty_pantry
from to via pattern
ui/screens/shopping/ShoppingScreen.kt ui/components/empty/EmptyState.kt same EmptyState pattern with empty_shopping_* empty_shopping
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-CONTEXT.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-RESEARCH.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-UI-SPEC.md @.planning/phases/02.1-app-shell-navigation-search-foundation/02.1-PATTERNS.md @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 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):

// 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:

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
Task 1: Extend strings.xml with empty-state copy and verify shared search keys composeApp/src/commonMain/composeResources/values/strings.xml - 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 Open `composeApp/src/commonMain/composeResources/values/strings.xml`. Locate the closing `` 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.
./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 - 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 " 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. Task 2: Create EmptyState.kt — the reusable empty-state composable per D-13 / UI-SPEC line 183 composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/components/empty/EmptyState.kt - 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) 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.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - `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 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. Task 3: Create 4 tab ViewModels — pure StateFlow with no actions this phase 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 - 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 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.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - 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' ` 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 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. Task 4: Create 4 tab Screens — inline title + EmptyState centered, all reading RecipeTheme tokens 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 - 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) 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..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 = ...)`.
./gradlew :composeApp:compileKotlinIosSimulatorArm64 -q - 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 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. - 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

<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>
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).