--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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): ```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 = _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 '`. 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 Twój plan tygodnia czeka Wkrótce zobaczysz tu zaplanowane posiłki. Tu pojawi się Twoja książka kucharska Po dodaniu pierwszych przepisów zobaczysz je w tym miejscu. Spiżarnia jest jeszcze pusta Wkrótce zobaczysz tu wszystko, co masz pod ręką. Lista zakupów czeka na Twój plan Gdy zaplanujesz tydzień, zobaczysz tu, czego brakuje. ``` 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 ' - 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 = _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` etc. */ data class RecipesState(val isEmpty: Boolean = true) class RecipesViewModel : ViewModel() { private val _state = MutableStateFlow(RecipesState()) val state: StateFlow = _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 = _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 = _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 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. 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).