Implement main app navigation
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user