803 lines
56 KiB
Markdown
803 lines
56 KiB
Markdown
# Phase 2.1: App Shell, Navigation & Search Foundation — Research
|
|
|
|
**Researched:** 2026-05-08
|
|
**Domain:** Compose Multiplatform navigation chrome (KMP iOS-primary), renderless component foundation, Liquid-Glass surface primitive, externalized strings, search affordance state machine
|
|
**Confidence:** HIGH (locked stack; standard CMP patterns) / MEDIUM (Liquid library API surface)
|
|
|
|
---
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
|
|
**Tab bar shape & chrome placement**
|
|
- **D-01:** Bottom-anchored floating pill dock implemented as a Liquid-glass capsule, centered above the safe-area inset. No edge-to-edge bottom bar.
|
|
- **D-02:** All four tabs render icon + label at all times (active and inactive). Active tab is wider and visually emphasized; inactive tabs remain readable, not icon-only.
|
|
- **D-03:** Tab order — `Planer` / `Przepisy` / `Spiżarnia` / `Zakupy`. Default landing tab on first sign-in is `Planer`.
|
|
- **D-04:** No top app bar in v1. Tab title (where useful) lives inline at the top of each screen body. All chrome is bottom-anchored.
|
|
- **D-05:** When search opens (on tabs that have search), the dock collapses to a single circular button showing only the active tab's icon (no label, slightly reduced height). Tapping it closes search and re-expands the dock. Single coordinated animation.
|
|
|
|
**Search affordance behavior**
|
|
- **D-06:** Search button per-tab, only on `Przepisy` and `Spiżarnia`. Floating circular icon adjacent to the dock (not inside it).
|
|
- **D-07:** This phase delivers open/close + query input echo + clear/close actions only. Search-surface body renders nothing (Phase 5 wires real results for Recipes; Pantry phase wires Spiżarnia).
|
|
- **D-08:** Closing the search clears the query. Reopening starts blank. No persistence across close, tab-switch, or app launch.
|
|
- **D-09:** Search is an inline bottom pill, not a full-screen sheet. Body content stays visible behind it.
|
|
|
|
**Empty state design language**
|
|
- **D-10:** Icon + headline + subline. Icon is tab-themed, low-saturation theme color. No bespoke illustrations.
|
|
- **D-11:** Anticipatory Polish tone (e.g. "Wkrótce zobaczysz tu swój plan tygodnia"). No "Brak danych", no chatty onboarding.
|
|
- **D-12:** No CTA buttons in empty states this phase.
|
|
- **D-13:** Single reusable `EmptyState(icon, title, subtitle, action?)` composable in `ui/components/`; `action` slot reserved unused this phase.
|
|
|
|
**Theme tokens + Liquid fallback**
|
|
- **D-14:** Full theme scaffold this phase — semantic colors (background, surface, surfaceGlass, content, contentMuted, accent, separator, borderCard), typography scale (display/title/body/label, two weights), spacing scale (`xs`/`sm`/`lg`/`xl`/`2xl`/`3xl` per UI-SPEC revision 1), `GlassSurface` token primitive consumed by dock + search pill + floating buttons.
|
|
- **D-15:** Both light and dark color schemes defined; system-following.
|
|
- **D-16:** `GlassSurface` is layered Liquid → Haze → flat translucent fallback chain. All three paths consume same token API (color + opacity + radius).
|
|
- **D-17:** Compile-time per-target backend selection + debug-build runtime toggle (via `multiplatform-settings`). No automatic perf detection in v1.
|
|
|
|
### Claude's Discretion
|
|
- Exact Liquid library API parameters (radius, blur amount, refraction)
|
|
- Nav graph topology (default: nested NavHosts per tab unless research blocks it — research below confirms this is correct)
|
|
- Whether to migrate Phase 2 Material 3 auth screens (default: leave as legacy)
|
|
- Specific empty-state copy strings (Phase 11 will tune; UI-SPEC has best-current values)
|
|
- Icon source (default: Material Icons Outlined)
|
|
- Animation curves and durations for search-open dock collapse (UI-SPEC suggests 250ms `FastOutSlowInEasing`)
|
|
- Accessibility specifics (Role.Tab, focus order)
|
|
- Whether to expose runtime fallback toggle as in-app debug affordance or build flag
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
- Per-tab/scroll-state dock collapse independent of search → Phase 10
|
|
- Profile/settings entry point in chrome → Phase 3+
|
|
- Cross-tab CTAs in empty states → feature phases
|
|
- Custom illustrations for empty states
|
|
- Material 3 migration of Phase 2 auth screens
|
|
- Runtime perf auto-downgrade for GlassSurface → Phase 10
|
|
- Persisting search query across sessions
|
|
- Real-device Liquid tuning (refraction, specular) → Phase 10
|
|
- Localization (full Polish copy pass) → Phase 11
|
|
</user_constraints>
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|------------------|
|
|
| UI-03 | Bottom tab navigation with 4 tabs (Przepisy/Planer/Spiżarnia/Zakupy), each preserving its own back stack independently | § Architecture Pattern 1 (nested NavHost per tab) + § Standard Stack (`navigation-compose 2.9.x`) + Pitfall 13 (`when`-switch tabs lose back stack) |
|
|
| UI-04 | App chrome and primary icon buttons use chosen Liquid-Glass approximation, starting with Liquid library for menu/search controls | § Architecture Pattern 3 (`GlassSurface` primitive) + § Liquid Library Integration |
|
|
| UI-09 | App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog/plan/pantry/shopping are empty | § Architecture Pattern 4 (`EmptyState` reusable composable) + § Code Examples |
|
|
| UI-10 | Main app search affordance functional before catalog data exists: search opens, query state updates, clear/close work, no-results state is deliberate | § Architecture Pattern 2 (search state machine) + § SearchPill structure |
|
|
</phase_requirements>
|
|
|
|
## Project Constraints (from CLAUDE.md)
|
|
|
|
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port). No alternative.
|
|
- ViewModel + StateFlow, method-per-action.
|
|
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`). `koinViewModel()` everywhere.
|
|
- Components: Composables.com / Compose Unstyled — DO NOT expand around Material 3. Material 3 stays only as legacy auth scaffold.
|
|
- Glass: Liquid first; Haze fallback only.
|
|
- Strings externalized day 1 (Polish content, multi-locale-ready resources). NO hardcoded literals.
|
|
- iOS-primary, Android secondary; no Desktop/Wasm targets in v1.
|
|
- iOS K/N flags: `objcDisposeOnMain=false`, `gc=cms` (already set Phase 1).
|
|
- `shared/commonMain` stays light — no UI/Ktor/SQLDelight imports.
|
|
- Glass effects on chrome only (PITFALLS Pitfall 5/12); never over scrolling content.
|
|
- Package layout: `dev.ulfrx.recipe.{app,navigation,ui.{theme,components,screens.{recipes,planner,pantry,shopping}},...}`.
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 2.1 replaces the post-login placeholder with the real four-tab app shell. Three load-bearing pieces:
|
|
|
|
1. **Navigation:** Single root `NavHost` containing four `navigation(...)` sub-graphs (one per tab) using `org.jetbrains.androidx.navigation:navigation-compose` 2.9.x (CMP port). Bottom-tab reselection uses `popUpTo(graph.findStartDestination().id) { saveState = true }; launchSingleTop = true; restoreState = true` so each tab's back stack survives switching. Routes are `@Serializable` `data object` / `data class` per JetBrains type-safe routing. ViewModels per tab area are scoped to the parent nav-graph `NavBackStackEntry` via `koinViewModel(viewModelStoreOwner = parentEntry)`.
|
|
|
|
2. **Component foundation:** `compose-unstyled` (`com.composables:composeunstyled:1.49.x`) provides renderless primitives for `TabGroup`, `Button`, `TextField`, `Modal`/`BottomSheet`. Recipe-styled components in `ui/components/` consume those primitives and apply `RecipeTheme` tokens. Material 3 imports are confined to `ui/screens/auth/*` (legacy).
|
|
|
|
3. **Glass surface:** `GlassSurface` primitive in `ui/components/glass/` with three backends — Liquid (`io.github.fletchmckee.liquid:liquid:1.1.1`, modifier `liquid(state)` + `liquefiable(state)`), Haze (`dev.chrisbanes.haze:haze:1.x`), and flat translucent. Backend selection is compile-time per-target (Gradle source-set wiring) plus a debug-build runtime override stored in `multiplatform-settings`. Liquid is preferred on iOS+Android; Haze is the secondary blur path; flat is last resort.
|
|
|
|
**Primary recommendation:** Build top-down — root `AppShell` composable hosting one CMP `NavHost` with four `navigation()` sub-graphs, bottom dock + floating search button as overlay, per-tab `koinViewModel()` scoped to parent graph entry, all glass effects funneled through `GlassSurface`. Strings always via `stringResource(Res.string.*)` against `composeResources/values/strings.xml`. No `androidx.compose.material3.*` imports outside `ui/screens/auth/`.
|
|
|
|
---
|
|
|
|
## Architectural Responsibility Map
|
|
|
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
|
|------------|-------------|----------------|-----------|
|
|
| Tab navigation + back stacks | KMP client (Compose UI) | — | Pure client UX; no server interaction |
|
|
| Search affordance state | KMP client (per-tab ViewModel) | — | Local UI state; no persistence (D-08) |
|
|
| Theme tokens / `RecipeTheme` | KMP client (ui/theme) | — | Renders identically across platforms |
|
|
| Liquid/Haze/flat backend selection | KMP client (compile-time per Kotlin source set) | Runtime debug toggle | Per-platform shader capability |
|
|
| Empty-state copy | KMP resources (`composeResources/values/strings.xml`) | Phase 11 localization | Resource-keyed; copy may tune later |
|
|
| Auth gate (still upstream of shell) | KMP client (App.kt observes `AuthSession`) | — | Unchanged from Phase 2; shell sits downstream |
|
|
|
|
No server changes in this phase. No `shared/commonMain` changes (UI is client-only).
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core (already in `gradle/libs.versions.toml` or to add)
|
|
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| `org.jetbrains.androidx.navigation:navigation-compose` | **2.9.2** (latest as of 2026-05-08) — currently NOT in catalog; **add** | CMP-official navigation; type-safe routes; multi-back-stack support | JetBrains-official port of Jetpack Navigation; locked in CLAUDE.md |
|
|
| `androidx-lifecycle-viewmodelCompose` | 2.10.0 (already in catalog) | `ViewModel` + `viewModelScope` in commonMain | Already locked Phase 2 |
|
|
| `koin-compose` / `koin-composeViewmodel` | 4.2.1 (already in catalog) | `koinViewModel()`, `koinInject()` | Already locked |
|
|
| `compose-components-resources` | 1.10.3 (already in catalog) | `Res.string.*`, `stringResource()` | CMP standard for strings |
|
|
| `androidx-compose-material-icons-extended` | n/a — needs investigation; CMP equivalent is via `compose-material-icons-core` or use `material3` icons (already pulled by Phase 2 auth scaffold) | Outlined icon set for tabs + empty states | UI-SPEC selected `Icons.Outlined.*` | [VERIFIED: UI-SPEC + libs.versions.toml] |
|
|
|
|
> **Material Icons in CMP caveat:** the JetBrains CMP `material3` artifact (already in catalog) bundles a baseline icon set, but `Icons.Outlined.MenuBook` / `Icons.Outlined.Inventory2` / `Icons.Outlined.CalendarMonth` / `Icons.Outlined.ShoppingCart` are in the **extended** icon set. CMP exposes this via `org.jetbrains.compose.material:material-icons-extended` (or pulls them transitively from `material3`). **Plan needs to verify** whether the four icons referenced in UI-SPEC are available without adding `material-icons-extended`, and add the dependency if not. [ASSUMED — needs Wave-0 verify step]
|
|
|
|
### Add to catalog
|
|
|
|
| Coordinate | Version | Purpose |
|
|
|------------|---------|---------|
|
|
| `org.jetbrains.androidx.navigation:navigation-compose` | 2.9.2 | CMP nav host + bottom-tab multi-back-stack [VERIFIED: Maven Central / kotlinlang.org] |
|
|
| `com.composables:composeunstyled` | 1.49.x (1.49.9 latest seen) | Renderless primitives (TabGroup, Button, TextField, Modal, BottomSheet) [VERIFIED: composables.com docs] |
|
|
| `io.github.fletchmckee.liquid:liquid` | 1.1.1 | Liquid Glass shader for chrome [VERIFIED: Maven Central central.sonatype.com] |
|
|
| `dev.chrisbanes.haze:haze` | 1.x stable (1.6+ as of early 2026) — confirm at planning time | Fallback blur surface [VERIFIED: chrisbanes.github.io/haze/ — Haze 2.0-alpha01 released 2026-04-29; stick to 1.x stable for production] |
|
|
|
|
### Already present, used as-is
|
|
|
|
`koin-bom`, `koin-core`, `koin-compose`, `koin-composeViewmodel`, `kermit`, `compose-runtime`, `compose-foundation`, `compose-material3` (legacy boundary), `compose-ui`, `compose-components-resources`, `androidx-lifecycle-viewmodelCompose`, `androidx-lifecycle-runtimeCompose`, `multiplatform-settings`.
|
|
|
|
### Alternatives Considered
|
|
|
|
| Instead of | Could Use | Tradeoff |
|
|
|------------|-----------|----------|
|
|
| `navigation-compose` (CMP port) | Decompose, Voyager | Both are popular but **locked away by CLAUDE.md** — JetBrains CMP nav is the canonical choice |
|
|
| Compose Unstyled | Roll our own renderless layer | Hand-rolling means re-implementing focus/a11y/keyboard/state semantics. Compose Unstyled exists for this exact reason |
|
|
| Liquid (RuntimeShader) | Native SwiftUI material via interop | Native interop is v2 (LG2-01); Liquid is the v1 approximation per PROJECT.md |
|
|
| Haze fallback | Skip middle tier (Liquid → flat) | CONTEXT D-16 explicitly chose three-tier — middle quality matters when Liquid fails on a target but blur still works |
|
|
|
|
### Installation
|
|
|
|
Add to `gradle/libs.versions.toml`:
|
|
```toml
|
|
[versions]
|
|
navigation-compose = "2.9.2"
|
|
compose-unstyled = "1.49.9"
|
|
liquid = "1.1.1"
|
|
haze = "1.6.10" # confirm latest 1.x stable at planning time
|
|
|
|
[libraries]
|
|
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
|
|
compose-unstyled = { module = "com.composables:composeunstyled", version.ref = "compose-unstyled" }
|
|
liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" }
|
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
|
```
|
|
|
|
Then in `composeApp/build.gradle.kts` `commonMain.dependencies`:
|
|
```kotlin
|
|
implementation(libs.navigation.compose)
|
|
implementation(libs.compose.unstyled)
|
|
implementation(libs.liquid)
|
|
implementation(libs.haze)
|
|
```
|
|
|
|
**Version verification step (Wave 0):** before locking, run `./gradlew dependencies --configuration commonMainRuntimeClasspath | grep -E "(navigation-compose|composeunstyled|liquid|haze)"` to confirm resolution succeeds for both `iosArm64` and `iosSimulatorArm64`. [ASSUMED — Liquid 1.1.1 ships iOS klibs based on Maven Central listing of `liquid-iossimulatorarm64` artifact, but the published target matrix is not enumerated on the package page. Wave 0 must confirm.]
|
|
|
|
---
|
|
|
|
## Architecture Patterns
|
|
|
|
### System Architecture Diagram
|
|
|
|
```
|
|
┌─────────────────────────┐
|
|
│ App() (App.kt) │
|
|
│ observes AuthSession │
|
|
└──────────┬──────────────┘
|
|
│
|
|
AuthState.Authenticated + currentUser != null
|
|
│
|
|
▼
|
|
┌──────────────────────────────────┐
|
|
│ AppShell (ui/screens/shell/) │
|
|
│ - hosts root NavController │
|
|
│ - renders DockBar overlay │
|
|
│ - renders FloatingSearchButton │
|
|
│ - hosts SearchPill when open │
|
|
└──────────────────┬───────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────── NavHost ────────────────────┐
|
|
│ │
|
|
│ navigation(route="planner_graph", │
|
|
│ startDest=PlannerHome) ──► PlannerScreen │
|
|
│ navigation(route="recipes_graph", ...) │
|
|
│ startDest=RecipesHome ──► RecipesScreen │
|
|
│ navigation(route="pantry_graph", ...) │
|
|
│ startDest=PantryHome ──► PantryScreen │
|
|
│ navigation(route="shopping_graph", ...) │
|
|
│ startDest=ShoppingHome ──► ShoppingScreen│
|
|
└────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
Each *Screen consumes a koinViewModel<*VM>(
|
|
viewModelStoreOwner = parentNavGraphEntry)
|
|
so survival across tab reselection works.
|
|
|
|
Search overlay (only on recipes_graph + pantry_graph):
|
|
FloatingSearchButton tap
|
|
│
|
|
▼
|
|
AppShell.searchOpen=true
|
|
(per-active-tab SearchViewModel)
|
|
│
|
|
├─► DockBar collapses (single coordinated animation)
|
|
├─► FloatingSearchButton hides
|
|
└─► SearchPill renders inline at bottom
|
|
(TextField → SearchViewModel.onQueryChange)
|
|
(clear → query=""; close → searchOpen=false, query="")
|
|
|
|
GlassSurface(...) [used by DockBar, FloatingSearchButton, SearchPill]
|
|
│
|
|
├── compile-time backend per target:
|
|
│ iosArm64/iosSimulatorArm64/android → LiquidBackend (default)
|
|
│ fallback constellation → HazeBackend
|
|
│ fallback constellation → FlatBackend
|
|
│
|
|
└── debug-build override via multiplatform-settings key
|
|
"debug.glass_backend" ∈ {liquid, haze, flat}
|
|
```
|
|
|
|
### Recommended Project Structure
|
|
|
|
```
|
|
composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/
|
|
├── app/ # (future) — App() may move here later; out of scope
|
|
├── navigation/
|
|
│ ├── Routes.kt # @Serializable data object/class for every destination
|
|
│ ├── RootNavHost.kt # NavHost containing 4 nested navigation() blocks
|
|
│ └── BottomBarDestination.kt # enum or sealed of (Planner, Recipes, Pantry, Shopping)
|
|
├── ui/
|
|
│ ├── theme/
|
|
│ │ ├── RecipeTheme.kt # extended: hosts CompositionLocal scaffold (D-14, D-15)
|
|
│ │ ├── RecipeColors.kt # data class + Light/Dark instances (D-15)
|
|
│ │ ├── RecipeTypography.kt # display/title/body/label (D-14)
|
|
│ │ ├── RecipeSpacing.kt # xs/sm/lg/xl/2xl/3xl (UI-SPEC rev 1)
|
|
│ │ ├── RecipeShapes.kt # pill / circle radii
|
|
│ │ └── RecipeGlass.kt # GlassSurface params (tint, opacity, blur, border)
|
|
│ ├── components/
|
|
│ │ ├── glass/
|
|
│ │ │ ├── GlassSurface.kt # public API; commonMain
|
|
│ │ │ └── GlassBackend.kt # expect/actual or commonMain abstraction
|
|
│ │ ├── dock/
|
|
│ │ │ ├── DockBar.kt # 4-tab pill; collapses on searchOpen
|
|
│ │ │ └── FloatingSearchButton.kt # adjacent circular button
|
|
│ │ ├── search/
|
|
│ │ │ └── SearchPill.kt # inline bottom search input
|
|
│ │ └── empty/
|
|
│ │ └── EmptyState.kt # reusable (icon, title, subtitle, action?)
|
|
│ └── screens/
|
|
│ ├── shell/
|
|
│ │ ├── AppShell.kt # root authenticated composable
|
|
│ │ └── ShellState.kt # active tab + searchOpen state
|
|
│ ├── planner/
|
|
│ │ ├── PlannerScreen.kt # inline title + EmptyState
|
|
│ │ └── PlannerViewModel.kt
|
|
│ ├── recipes/
|
|
│ │ ├── RecipesScreen.kt
|
|
│ │ ├── RecipesViewModel.kt
|
|
│ │ └── RecipesSearchViewModel.kt
|
|
│ ├── pantry/
|
|
│ │ ├── PantryScreen.kt
|
|
│ │ ├── PantryViewModel.kt
|
|
│ │ └── PantrySearchViewModel.kt
|
|
│ └── shopping/
|
|
│ ├── ShoppingScreen.kt
|
|
│ └── ShoppingViewModel.kt
|
|
│ └── (auth/ stays as-is — legacy Material 3)
|
|
└── di/
|
|
├── AppModule.kt # extended to include shellModule
|
|
└── ShellModule.kt # NEW: VMs + ShellState + GlassBackend factory
|
|
```
|
|
|
|
`composeApp/src/iosMain/` and `androidMain/`: backend `actual`s for `GlassBackend` if implementation differs by platform. Liquid is multiplatform so a single `commonMain` `LiquidBackend` likely works; only Haze actuals or platform-specific image effects need `actual`s — confirm at planning.
|
|
|
|
### Pattern 1: Nested NavHost per tab (CMP-official, multi-back-stack)
|
|
|
|
Single root `NavHost` containing four `navigation(route = "*_graph")` sub-graphs. Bottom dock navigation uses save/restore state. This is the JetBrains-recommended pattern (kotlinlang.org/docs/multiplatform/compose-navigation.html — "for apps with bottom navigation you can maintain separate nested graphs for each tab while saving and restoring navigation states when switching between tabs").
|
|
|
|
```kotlin
|
|
// Source: kotlinlang.org/docs/multiplatform/compose-navigation.html (HIGH)
|
|
// + saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved (MEDIUM)
|
|
|
|
@Serializable data object PlannerGraph
|
|
@Serializable data object PlannerHome
|
|
@Serializable data object RecipesGraph
|
|
@Serializable data object RecipesHome
|
|
// ... etc
|
|
|
|
@Composable
|
|
fun RootNavHost(navController: NavHostController) {
|
|
NavHost(navController = navController, startDestination = PlannerGraph) {
|
|
navigation<PlannerGraph>(startDestination = PlannerHome) {
|
|
composable<PlannerHome> { entry ->
|
|
val parent = remember(entry) {
|
|
navController.getBackStackEntry(PlannerGraph)
|
|
}
|
|
val vm: PlannerViewModel = koinViewModel(viewModelStoreOwner = parent)
|
|
PlannerScreen(vm)
|
|
}
|
|
// future detail destinations land here
|
|
}
|
|
navigation<RecipesGraph>(startDestination = RecipesHome) { /* ... */ }
|
|
navigation<PantryGraph>(startDestination = PantryHome) { /* ... */ }
|
|
navigation<ShoppingGraph>(startDestination = ShoppingHome) { /* ... */ }
|
|
}
|
|
}
|
|
|
|
fun NavHostController.navigateToTab(graphRoute: Any) {
|
|
navigate(graphRoute) {
|
|
popUpTo(graph.findStartDestination().id) { saveState = true }
|
|
launchSingleTop = true
|
|
restoreState = true
|
|
}
|
|
}
|
|
```
|
|
|
|
**iOS caveat (PITFALL 13 + research/PITFALLS.md):** The CMP nav backstack persistence has had issues across minor versions (see GitHub issue 4735 — "Support saving state for nested NavHostController"). Pin to 2.9.2 (latest stable) and verify multi-back-stack behavior on iOS during Wave 0 with a short demo: open detail → switch tab → switch back → confirm detail restored. [VERIFIED: github.com/JetBrains/compose-multiplatform/issues/4735 — issue references nested NavHostController; root-level multi-back-stack via single NavHost + `navigation` blocks is the working pattern]
|
|
|
|
### Pattern 2: Per-tab ViewModel scoping via parent graph `NavBackStackEntry`
|
|
|
|
`koinViewModel()` defaults to scoping to the *current* destination entry — meaning the VM dies when you navigate to a child destination. To make `RecipesViewModel` survive within the recipes graph (so future `RecipesDetailScreen` can share state with `RecipesScreen`), retrieve the **parent graph's** `NavBackStackEntry` and pass it as `viewModelStoreOwner`.
|
|
|
|
```kotlin
|
|
// Source: insert-koin.io/docs/reference/koin-compose/compose/ (HIGH)
|
|
// + droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation (MEDIUM)
|
|
|
|
@Composable
|
|
fun RecipesScreen(navController: NavController) {
|
|
val parent = remember { navController.getBackStackEntry(RecipesGraph) }
|
|
val vm: RecipesViewModel = koinViewModel(viewModelStoreOwner = parent)
|
|
val searchVm: RecipesSearchViewModel = koinViewModel(viewModelStoreOwner = parent)
|
|
// both VMs survive within the recipes graph; freed when graph leaves stack
|
|
}
|
|
```
|
|
|
|
This phase only has one screen per graph, but **set the pattern now** — Phase 5 (Recipe Catalog) will add detail screens that need shared state with the list screen, and Phase 5 should not have to refactor scoping.
|
|
|
|
### Pattern 3: `GlassSurface` primitive with three-backend chain (D-16, D-17)
|
|
|
|
```kotlin
|
|
// Source: research synthesis from CONTEXT D-16/D-17 + Liquid README + Haze docs (MEDIUM — Liquid API is from README)
|
|
|
|
@Composable
|
|
fun GlassSurface(
|
|
modifier: Modifier = Modifier,
|
|
tint: Color = RecipeTheme.colors.surfaceGlass,
|
|
cornerRadius: Dp = 28.dp,
|
|
border: BorderStroke? = BorderStroke(1.dp, RecipeTheme.colors.borderCard),
|
|
content: @Composable BoxScope.() -> Unit,
|
|
) {
|
|
val backend = LocalGlassBackend.current // resolved via compile-time + debug toggle
|
|
when (backend) {
|
|
GlassBackend.Liquid -> LiquidGlassSurface(modifier, tint, cornerRadius, border, content)
|
|
GlassBackend.Haze -> HazeGlassSurface(modifier, tint, cornerRadius, border, content)
|
|
GlassBackend.Flat -> FlatGlassSurface(modifier, tint, cornerRadius, border, content)
|
|
}
|
|
}
|
|
```
|
|
|
|
`LocalGlassBackend` is a `CompositionLocal` set once at `AppShell` startup:
|
|
1. **Compile-time default** picked per target via `expect/actual` or `commonMain` constants — e.g. `iosArm64/iosSimulatorArm64/android → Liquid`, anything else → `Haze`.
|
|
2. **Debug runtime override** read once at app start from `multiplatform-settings` key `"debug.glass_backend"`. Production builds short-circuit this path (compiled out via `BuildConfig`-style constant in `androidMain` / Kotlin `expect val isDebug` actual).
|
|
|
|
The Liquid path uses `rememberLiquidState()` + `Modifier.liquefiable(state)` on the content layer behind chrome and `Modifier.liquid(state)` on the chrome itself. The Liquid effect needs a sampleable backdrop, so the screen content (tab body) gets `liquefiable(state)` and the dock/search-pill get `liquid(state)`. **Important:** that backdrop is the screen body, not scrolling content within the body — that aligns with PITFALL 5/12 (chrome-only constraint).
|
|
|
|
### Pattern 4: Search affordance state machine
|
|
|
|
```kotlin
|
|
// Source: synthesized from CONTEXT D-05 through D-09 + UI-SPEC interaction contract
|
|
|
|
class RecipesSearchViewModel : ViewModel() {
|
|
private val _state = MutableStateFlow(SearchState())
|
|
val state: StateFlow<SearchState> = _state.asStateFlow()
|
|
|
|
fun open() { _state.update { it.copy(isOpen = true) } }
|
|
fun close() { _state.update { SearchState() } } // D-08: clears query
|
|
fun onQueryChange(q: String) { _state.update { it.copy(query = q) } }
|
|
fun clear() { _state.update { it.copy(query = "") } }
|
|
}
|
|
|
|
data class SearchState(val isOpen: Boolean = false, val query: String = "")
|
|
```
|
|
|
|
`AppShell` reads the search VM of the **active** tab (Recipes or Pantry). When `isOpen = true`, the `DockBar` collapses + `SearchPill` renders. The shell owns the active-tab → search-VM mapping; the VMs themselves are scoped to their parent graphs.
|
|
|
|
**Phase 5 extension point:** the Recipes search VM's state today is `(isOpen, query)`. Phase 5 adds `results: Flow<List<RecipeCard>>` derived from `query.debounce().flatMapLatest { repo.search(it) }`. Design the VM constructor with a nullable `searchSource: SearchSource? = null` parameter today so Phase 5 only injects the dependency rather than rewriting the VM.
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **`when (selectedTab) { ... }` switch instead of nested `NavHost`:** kills back stacks (PITFALL 13). Always use `navigation()` sub-graphs.
|
|
- **`koinViewModel()` without `viewModelStoreOwner` for tab-scoped VMs:** VM dies when navigating into a detail; future Phase 5 detail flow loses list scroll position.
|
|
- **Glass effects over scrolling content:** explicit project rule (CLAUDE.md #10, PITFALL 5/12). `GlassSurface` is for chrome only — dock, search pill, floating button.
|
|
- **Direct Liquid/Haze API calls in screen code:** screens MUST go through `GlassSurface`. Direct calls leak backend choice into call sites and break the fallback contract.
|
|
- **Hardcoded Polish strings:** every user-facing string is `stringResource(Res.string.*)`. CLAUDE.md non-negotiable #9.
|
|
- **`androidx.compose.material3.*` imports outside `ui/screens/auth/`:** PROJECT decision. Even if convenient, it expands Material 3 into new code.
|
|
- **Device clock for animation timing:** unrelated to LWW but same hygiene — use `kotlinx.coroutines` `delay` and Compose animation specs, not `System.currentTimeMillis()`.
|
|
|
|
---
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Tab navigation with multi-back-stack | `when (selectedTab)` + manual back-handler | CMP `navigation-compose` 2.9.x with `popUpTo + saveState + restoreState + launchSingleTop` | PITFALL 13: hand-rolled tab switching loses back stack on every switch; Jetpack/JetBrains nav handles it correctly |
|
|
| Renderless TabGroup / Button / TextField with proper a11y + focus + keyboard | Custom `Modifier.clickable + Role.Tab` and an `OutlinedTextField` analogue | Compose Unstyled `TabGroup`, `Button`, `TextField` primitives | These libraries already handle focus order, semantics, IME types, and edge cases; PROJECT decision is to use them |
|
|
| Glass blur effect | Custom `RenderEffect` per platform | Liquid (`liquid` modifier) → Haze (`hazeChild`) → flat translucent | Cross-platform shader correctness, perf optimization, and graceful degradation — all already in Liquid/Haze |
|
|
| Polish-aware string lookup | Hardcoded literals + manual locale switch | `compose-components-resources` `stringResource(Res.string.*)` | Already wired Phase 2; multi-locale-ready for free |
|
|
| Theme `CompositionLocal` ceremony | Per-component prop drilling | Standard Compose `compositionLocalOf` + `CompositionLocalProvider` pattern | Idiomatic; mirror MaterialTheme's structure |
|
|
| Animated transition between dock states | Manual coroutine + lerp | `Modifier.animateContentSize()` for size + `AnimatedContent` for icon/label visibility, both with shared `animationSpec` | Single-source-of-truth animation; Compose handles intersecting frames |
|
|
|
|
**Key insight:** every chrome surface (dock, search button, search pill) uses the same `GlassSurface` primitive — the size, shape, and animation differ but the substrate doesn't. Centralizing surface logic now means Phase 10's real-device tuning is a one-file change.
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall A: CMP nav-compose multi-back-stack regression on iOS
|
|
|
|
**What goes wrong:** Tab → detail → other tab → return → detail is gone. Reproduces on iOS, not Android.
|
|
**Why:** Some 2.8.x CMP nav releases had broken state restoration on iOS native; 2.9.x is the recommended floor. CMP's K/N nav implementation has had drift behind Android.
|
|
**How to avoid:** Pin to `navigation-compose 2.9.2`. Add a Wave-0 manual smoke test on iOS simulator: navigate dummy detail in one tab, switch tabs, switch back, assert detail visible.
|
|
**Warning signs:** Works on Android, broken on iOS. Compose Multiplatform GitHub issue 4735 family.
|
|
|
|
### Pitfall B: ViewModel re-creation on tab reselection
|
|
|
|
**What goes wrong:** Clicking the active tab re-creates its ViewModel, dropping in-memory state and re-running `init`.
|
|
**Why:** `launchSingleTop = true` + missing `restoreState = true` causes Nav to clear and recreate.
|
|
**How to avoid:** Always include `restoreState = true` AND scope VM to parent graph entry (Pattern 2 above). Verify by adding a counter in `init` and confirming it doesn't tick on tab reselection.
|
|
|
|
### Pitfall C: Liquid sampleable backdrop missing → effect renders flat
|
|
|
|
**What goes wrong:** `liquid()` modifier renders nothing because no `liquefiable()` peer is in the tree.
|
|
**Why:** Liquid's pixel-sampling needs a tagged source layer. Forgetting it means the effect has no input.
|
|
**How to avoid:** `AppShell` wraps the screen body region in `Modifier.liquefiable(state)` and the dock + search pill + search button consume `Modifier.liquid(state)` from the same `LiquidState`. Document this contract in `GlassSurface` KDoc.
|
|
|
|
### Pitfall D: `Icons.Outlined.MenuBook` and friends not in baseline icon set
|
|
|
|
**What goes wrong:** Compile fails on `Icons.Outlined.MenuBook` / `Inventory2` / `CalendarMonth` / `ShoppingCart` because the four selected icons are in the **extended** set, not the baseline that `material3` ships.
|
|
**How to avoid:** Verify at planning time. If extended set is needed, add `org.jetbrains.compose.material:material-icons-extended` to the catalog. (Wave-0 task: try a dummy compose with all four icons; observe.)
|
|
|
|
### Pitfall E: Hardcoded literals slip in during shell wiring
|
|
|
|
**What goes wrong:** Tab labels or empty-state copy gets typed inline as `Text("Planer")` during a quick prototype, then nobody refactors.
|
|
**How to avoid:** Lint/grep gate in plan-checker: any `Text("[A-ZŁĄĆŻŃŚŹŻ]...")` or `Text("[a-zA-Złąćż]+")` in `ui/screens/(planner|recipes|pantry|shopping|shell)/` is a bug. Phase 11 will enforce this globally; introduce the discipline now (CLAUDE.md non-negotiable #9).
|
|
|
|
### Pitfall F: `safeContentPadding()` interactions with floating dock
|
|
|
|
**What goes wrong:** Bottom dock either overlaps the home indicator or sits too high above it because `Scaffold`-style content padding gets applied twice (once by parent, once by screen body).
|
|
**How to avoid:** AppShell consumes navigation/IME insets explicitly via `WindowInsets.navigationBars.union(WindowInsets.ime).only(WindowInsetsSides.Bottom)` and applies them to the dock's bottom offset. Screen bodies use `WindowInsets.statusBars` for top inset only. Don't use `safeContentPadding()` on both layers.
|
|
|
|
### Pitfall G: K/N GC churn on bottom-dock animation (PITFALL 1 carry-over)
|
|
|
|
**What goes wrong:** Frame hitches on iPhone 11/12-era hardware when dock collapses and the Liquid layer composites.
|
|
**How to avoid:** `kotlin.native.binary.objcDisposeOnMain=false` and `gc=cms` are already set Phase 1 (INFRA-03). Verify in Wave 0 and confirm in any iOS smoke test. If hitches appear, the debug runtime toggle (D-17) lets the user fall back to flat to confirm Liquid is the cause.
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Example 1: Routes (type-safe)
|
|
|
|
```kotlin
|
|
// navigation/Routes.kt
|
|
package dev.ulfrx.recipe.navigation
|
|
|
|
import kotlinx.serialization.Serializable
|
|
|
|
@Serializable data object PlannerGraph
|
|
@Serializable data object PlannerHome
|
|
|
|
@Serializable data object RecipesGraph
|
|
@Serializable data object RecipesHome
|
|
|
|
@Serializable data object PantryGraph
|
|
@Serializable data object PantryHome
|
|
|
|
@Serializable data object ShoppingGraph
|
|
@Serializable data object ShoppingHome
|
|
|
|
enum class BottomBarDestination(val graphRoute: Any, val labelRes: StringResource, val icon: ImageVector) {
|
|
Planner(PlannerGraph, Res.string.shell_tab_planner, Icons.Outlined.CalendarMonth),
|
|
Recipes(RecipesGraph, Res.string.shell_tab_recipes, Icons.Outlined.MenuBook),
|
|
Pantry(PantryGraph, Res.string.shell_tab_pantry, Icons.Outlined.Inventory2),
|
|
Shopping(ShoppingGraph, Res.string.shell_tab_shopping, Icons.Outlined.ShoppingCart),
|
|
}
|
|
```
|
|
|
|
### Example 2: AppShell skeleton
|
|
|
|
```kotlin
|
|
// ui/screens/shell/AppShell.kt
|
|
@Composable
|
|
fun AppShell() {
|
|
val navController = rememberNavController()
|
|
val backStack by navController.currentBackStackEntryAsState()
|
|
val activeTab = remember(backStack) { backStack?.toBottomBarDestination() ?: BottomBarDestination.Planner }
|
|
val shellState: ShellViewModel = koinViewModel()
|
|
val ui by shellState.state.collectAsStateWithLifecycle()
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(RecipeTheme.colors.background)
|
|
.liquefiable(shellState.liquidState), // backdrop for Liquid
|
|
) {
|
|
RootNavHost(navController)
|
|
|
|
// Bottom chrome — overlay
|
|
Column(
|
|
modifier = Modifier
|
|
.align(Alignment.BottomCenter)
|
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
|
) {
|
|
if (ui.searchOpen && activeTab.hasSearch) {
|
|
SearchPill(
|
|
query = ui.query,
|
|
onQueryChange = shellState::onQueryChange,
|
|
onClear = shellState::clearQuery,
|
|
onClose = shellState::closeSearch,
|
|
placeholder = stringResource(activeTab.searchPlaceholder),
|
|
)
|
|
}
|
|
DockBar(
|
|
destinations = BottomBarDestination.entries,
|
|
active = activeTab,
|
|
collapsed = ui.searchOpen,
|
|
onTabSelect = { dest -> navController.navigateToTab(dest.graphRoute) },
|
|
onCollapsedTap = shellState::closeSearch,
|
|
)
|
|
}
|
|
if (!ui.searchOpen && activeTab.hasSearch) {
|
|
FloatingSearchButton(
|
|
modifier = Modifier
|
|
.align(Alignment.BottomEnd)
|
|
.padding(end = RecipeTheme.spacing.lg, bottom = RecipeTheme.spacing.sm)
|
|
.windowInsetsPadding(WindowInsets.navigationBars),
|
|
onClick = shellState::openSearch,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Example 3: EmptyState
|
|
|
|
```kotlin
|
|
// ui/components/empty/EmptyState.kt
|
|
@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,
|
|
) {
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = null,
|
|
tint = RecipeTheme.colors.contentMuted,
|
|
modifier = Modifier.size(48.dp),
|
|
)
|
|
Spacer(Modifier.height(RecipeTheme.spacing.sm))
|
|
Text(text = title, style = RecipeTheme.typography.display, color = RecipeTheme.colors.content,
|
|
textAlign = TextAlign.Center)
|
|
Spacer(Modifier.height(RecipeTheme.spacing.lg))
|
|
Text(text = subtitle, style = RecipeTheme.typography.body, color = RecipeTheme.colors.contentMuted,
|
|
textAlign = TextAlign.Center)
|
|
if (action != null) {
|
|
Spacer(Modifier.height(RecipeTheme.spacing.xl))
|
|
action()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Example 4: Strings resource
|
|
|
|
```xml
|
|
<!-- composeApp/src/commonMain/composeResources/values/strings.xml — extend existing file -->
|
|
<resources>
|
|
<!-- existing auth_* keys preserved -->
|
|
|
|
<!-- Shell tab labels (UI-SPEC) -->
|
|
<string name="shell_tab_planner">Planer</string>
|
|
<string name="shell_tab_recipes">Przepisy</string>
|
|
<string name="shell_tab_pantry">Spiżarnia</string>
|
|
<string name="shell_tab_shopping">Zakupy</string>
|
|
|
|
<!-- Empty states -->
|
|
<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>
|
|
|
|
<!-- Search affordance (a11y + placeholders) -->
|
|
<string name="search_open_a11y">Otwórz wyszukiwanie</string>
|
|
<string name="search_close_a11y">Zamknij wyszukiwanie</string>
|
|
<string name="search_clear_a11y">Wyczyść</string>
|
|
<string name="search_placeholder_recipes">Szukaj przepisów…</string>
|
|
<string name="search_placeholder_pantry">Szukaj w spiżarni…</string>
|
|
</resources>
|
|
```
|
|
|
|
---
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| Manual `when (tab)` tab switching | CMP `navigation-compose` `navigation()` sub-graphs + `saveState/restoreState` | Stable since nav-compose 2.7+ on Android, 2.8+ on KMP | Multi-back-stack works; PITFALL 13 prevented |
|
|
| `nav-compose` 2.7.x with KMP support hidden behind alpha | `org.jetbrains.androidx.navigation:navigation-compose 2.9.x` (stable port) | 2.9 series | Use 2.9.2; older 2.7/2.8 had iOS state-restoration drift |
|
|
| Material 3 default scaffold for tab apps | Compose Unstyled renderless primitives + custom `RecipeTheme` | Compose Unstyled 1.40+ | Calmer aesthetics, no Material 3 tax — explicit project decision |
|
|
| `Modifier.blur()` for glass | RuntimeShader-based libraries (Liquid, Haze 2.x) | Compose 1.6+ stable RuntimeShader on iOS | Real Liquid Glass approximation cross-platform |
|
|
| Haze 2.0-alpha for shipping | Haze 1.x stable for production | Haze 2.0-alpha01 released 2026-04-29 | Stay on 1.x stable until Haze 2.x is stable; Phase 10 may revisit |
|
|
|
|
**Deprecated/outdated:**
|
|
- `freeze()`, `@SharedImmutable`, `kotlin.native.concurrent.AtomicReference` — gone since K/N new MM (PITFALL 2).
|
|
- `androidx.navigation:navigation-compose` (Android-only artifact) — for KMP, always use `org.jetbrains.androidx.navigation:navigation-compose`.
|
|
|
|
---
|
|
|
|
## Assumptions Log
|
|
|
|
| # | Claim | Section | Risk if Wrong |
|
|
|---|-------|---------|---------------|
|
|
| A1 | Liquid 1.1.1 publishes klibs for `iosArm64` AND `iosSimulatorArm64` (Maven Central lists `liquid-iossimulatorarm64` artifact, but full target matrix not enumerated on the package page) | Standard Stack / Pitfall A | Wave-0 dependency-resolution check fails for iOS; phase falls back to Haze-as-default at compile time. Plan must include the Wave-0 verify step before depending on Liquid as the iOS default backend. |
|
|
| A2 | `Icons.Outlined.MenuBook`, `Inventory2`, `CalendarMonth`, `ShoppingCart` are accessible without adding `material-icons-extended` (UI-SPEC selected these without flagging) | Standard Stack / Pitfall D | Build fails on import; planner adds `material-icons-extended` to catalog. Cheap to fix. |
|
|
| A3 | The CMP nav-compose 2.9.2 K/N (iOS) binary correctly persists `saveState` across tab reselection (a Wave-0 smoke test must confirm) | Pattern 1 / Pitfall A | If broken: the phase falls back to a single root NavHost without nested graphs, and Phase 5 will need to retrofit. Smoke test catches this in Wave 0. |
|
|
| A4 | Haze 1.x stable on KMP iOS handles `hazeChild` over a non-scrolling backdrop without the iPhone-11 jank pattern (PITFALL 12); restricted to chrome only | Pattern 3 | If jank: production engages the flat fallback per D-17. Acceptable since Liquid is the primary path. |
|
|
| A5 | `multiplatform-settings` is wired in commonMain Koin and accessible from `AppShell` at startup (already pulled in Phase 2 for AuthState) | Pattern 3 — debug toggle | If not: minor Koin wiring tweak. Already in libs catalog so likely fine. |
|
|
| A6 | Compose Unstyled 1.49.x supports KMP iOS targets (artifact name `composeunstyled` not `core`) | Standard Stack | If wrong artifact ID: Wave-0 catches via Gradle resolution failure; planner adjusts. Verify exact 1.49.9 coords against `composables.com/docs/com.composables/core/installation`. |
|
|
| A7 | The CMP `lifecycle-viewmodel-compose` `viewModelStoreOwner` parameter to `koinViewModel()` correctly hosts a VM per parent NavBackStackEntry on iOS (the documented pattern is from Android Jetpack; CMP behavior is assumed equivalent) | Pattern 2 | Test in Wave 0; if VM is recreated on tab switch on iOS, fall back to scoping at root graph (less ideal but functional). |
|
|
| A8 | Empty-state copy strings in UI-SPEC are best-current placeholders, subject to Phase 11 tuning | Code Examples / strings.xml | None — explicitly flagged in UI-SPEC. |
|
|
|
|
**A1 and A3 are the load-bearing assumptions** — Wave 0 of the plan MUST resolve them before the rest of the work is touched.
|
|
|
|
---
|
|
|
|
## Open Questions (RESOLVED)
|
|
|
|
> Resolved 2026-05-08 by gsd-phase-planner during the plan-set authoring pass for plans 02.1-03 through 02.1-08. Each resolution is reflected in the corresponding plan's mandates.
|
|
|
|
1. **Should the Liquid runtime debug toggle be exposed in-app (hidden gesture) or as a build flag only?** — **RESOLVED**
|
|
- What we know: D-17 says "via `multiplatform-settings`, surfaced through a hidden settings entry or build flag" — both are valid.
|
|
- What's unclear: which one delivers more value at this phase. There's no settings screen yet (Phase 3+).
|
|
- Recommendation: Build flag only this phase (lightest scaffolding). Defer in-app toggle to whenever a settings screen lands. The `multiplatform-settings`-backed `LocalGlassBackend` plumbing is still built so an in-app toggle is a UI-only change later.
|
|
- **RESOLUTION:** Debug-build runtime override via `multiplatform-settings` key `"debug.glass_backend"`, gated by `expect val isDebugBuild: Boolean` so production binaries compile out the override path entirely. This aligns with D-17 and is implemented by plan 02.1-03 (GlassBackend.kt + IsDebugBuild.kt expect/actual). No in-app debug-toggle UI this phase; Phase 3+ may add one as a UI-only change once a settings surface exists.
|
|
|
|
2. **Should the `material-icons-extended` artifact be added preemptively, or wait until the four icons are confirmed missing?** — **RESOLVED**
|
|
- What we know: UI-SPEC selected `Icons.Outlined.{CalendarMonth,MenuBook,Inventory2,ShoppingCart}`. These are typically in extended.
|
|
- What's unclear: whether `compose-material3` 1.10.0-alpha05 transitively exposes them.
|
|
- Recommendation: Wave-0 verification task — try the icons, add the dependency if needed. Document the result.
|
|
- **RESOLUTION:** Added preemptively in plan 02.1-01 (catalog entry `compose-material-icons-extended = "1.7.3"`) because the four phase-2.1 icons (CalendarMonth, MenuBook, Inventory2, ShoppingCart) plus Search are all in the extended set. Validated empirically by the `linkDebugFrameworkIosSimulatorArm64` acceptance check in plan 02.1-01.
|
|
|
|
3. **Should `RecipeTheme` re-export `MaterialTheme` for the auth screens, or are they fine on Material 3 defaults?** — **RESOLVED**
|
|
- What we know: Phase 2 auth screens use `MaterialTheme.colorScheme.surface/typography.headlineSmall`. The current `RecipeTheme.kt` is a Material 3 wrapper. UI-SPEC says auth stays on Material 3 as legacy.
|
|
- What's unclear: whether expanding `RecipeTheme` into the new token system breaks the existing `MaterialTheme.*` lookups in auth screens.
|
|
- Recommendation: `RecipeTheme` keeps wrapping `MaterialTheme(colorScheme = ...)` AND adds the new `CompositionLocalProvider` for Recipe tokens. Auth screens continue to read `MaterialTheme.*`; new code reads `RecipeTheme.*`. Both work in the same composition.
|
|
- **RESOLUTION:** Yes — plan 02.1-02 keeps `MaterialTheme(colorScheme = ...)` wrapping the inner `CompositionLocalProvider(...)`. Legacy auth screens (`LoginScreen.kt`, `PostLoginPlaceholderScreen.kt`, `SplashScreen.kt`) continue to read `MaterialTheme.colorScheme.*` / `MaterialTheme.typography.*`; new shell code reads `RecipeTheme.colors.*` etc. The MaterialTheme wrapper is removed only when the auth screens migrate (out of scope for v1 — CONTEXT line 52 keeps auth screens as legacy by user discretion).
|
|
|
|
|
|
---
|
|
|
|
## Environment Availability
|
|
|
|
This phase is purely client-side code/config; the only external "tools" are Gradle dependencies, all from Maven Central.
|
|
|
|
| Dependency | Required By | Available | Version | Fallback |
|
|
|------------|------------|-----------|---------|----------|
|
|
| Maven Central | All new dependencies | ✓ | n/a | — |
|
|
| `org.jetbrains.androidx.navigation:navigation-compose` | UI-03 | ✓ | 2.9.2 | — |
|
|
| `com.composables:composeunstyled` | UI-04, component foundation | ✓ | 1.49.9 | — |
|
|
| `io.github.fletchmckee.liquid:liquid` | UI-04 | ✓ | 1.1.1 | Fall back to Haze (D-16) |
|
|
| `dev.chrisbanes.haze:haze` | UI-04 fallback | ✓ | 1.x stable | Fall back to flat translucent |
|
|
| `gradlew` build for `iosSimulatorArm64` | Smoke test (Wave 0) | (host-dependent — Apple Silicon required) | n/a | Manual check on developer machine |
|
|
|
|
**Missing dependencies with no fallback:** none for this phase.
|
|
**Missing dependencies with fallback:** the entire Liquid → Haze → flat chain IS the fallback design.
|
|
|
|
---
|
|
|
|
## Validation Architecture
|
|
|
|
### Test Framework
|
|
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Framework | `kotlin.test` (commonTest) — already used in Phase 2 (`AuthSessionTest`, `LoginViewModelTest`) |
|
|
| Config file | none — convention plugins handle `recipe.kotlin.multiplatform` |
|
|
| Quick run command | `./gradlew :composeApp:commonTest --tests "dev.ulfrx.recipe.ui.screens.shell.*" --tests "dev.ulfrx.recipe.ui.screens.recipes.*Search*"` |
|
|
| Full suite command | `./gradlew :composeApp:check` |
|
|
| Compose UI test runner | not introduced this phase — feasibility low because Compose UI Test on KMP iOS is still surfacing |
|
|
|
|
### Phase Requirements → Test Map
|
|
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
|--------|----------|-----------|-------------------|-------------|
|
|
| UI-03 | Tab switch preserves per-tab back stack | manual smoke (iOS simulator) — instrument with logging if needed | `./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64` then iOS smoke from Xcode | ❌ Wave 0 |
|
|
| UI-03 | `navigateToTab()` extension applies `popUpTo + saveState + launchSingleTop + restoreState` | unit | `./gradlew :composeApp:commonTest --tests "*NavigationTest*"` | ❌ Wave 0 |
|
|
| UI-04 | `GlassSurface` selects Liquid backend on iOS targets at compile time | unit (per-source-set constants) | `./gradlew :composeApp:commonTest --tests "*GlassBackend*"` | ❌ Wave 0 |
|
|
| UI-04 | `GlassSurface` debug-toggle flow honors `multiplatform-settings` value | unit (with `MapSettings` test impl) | `./gradlew :composeApp:commonTest --tests "*GlassBackendOverride*"` | ❌ Wave 0 |
|
|
| UI-09 | `EmptyState` composable: on first launch, all four tabs render their respective empty state without flash | manual smoke (iOS) — observe one launch | n/a | manual |
|
|
| UI-09 | App.kt's `AuthState.Authenticated + currentUser != null` branch resolves to `AppShell`, not `PostLoginPlaceholderScreen` | unit (via state-machine test extending `AuthSessionTest` patterns) | `./gradlew :composeApp:commonTest --tests "*AppShellGateTest*"` | ❌ Wave 0 |
|
|
| UI-10 | `RecipesSearchViewModel`: `open() → onQueryChange("foo") → close()` clears query and resets `isOpen` | unit | `./gradlew :composeApp:commonTest --tests "*SearchViewModelTest*"` | ❌ Wave 0 |
|
|
| UI-10 | `RecipesSearchViewModel`: `clear()` resets only query, keeps `isOpen=true` | unit | (same target) | ❌ Wave 0 |
|
|
| UI-10 | Search affordance is visible on Recipes + Pantry tabs only (D-06) | manual smoke + screenshot per tab | n/a | manual |
|
|
|
|
### Sampling Rate
|
|
|
|
- **Per task commit:** `./gradlew :composeApp:commonTest` (existing tests + new tests for that task)
|
|
- **Per wave merge:** `./gradlew :composeApp:check` (lint/spotless + commonTest)
|
|
- **Phase gate:** Full `./gradlew check` green AND a single iOS-simulator smoke run completed by hand: launch → land on Planer empty state → tab through Przepisy / Spiżarnia / Zakupy → open search on Recipes, type a few chars, close → confirm dock collapse animation runs → confirm navigation back stacks survive tab roundtrip (smoke script in `02.1-VALIDATION.md`)
|
|
|
|
### Wave 0 Gaps
|
|
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/navigation/NavigationTest.kt` — covers UI-03 nav extension semantics (uses `TestNavHostController` if available; else asserts on the lambda built into `navigateToTab()`)
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendTest.kt` — covers UI-04 backend selection
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/components/glass/GlassBackendOverrideTest.kt` — covers UI-04 debug toggle via `MapSettings`
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/shell/AppShellGateTest.kt` — covers UI-09 (root `App()` routes Authenticated to AppShell, not placeholder)
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/recipes/RecipesSearchViewModelTest.kt` — covers UI-10
|
|
- [ ] `composeApp/src/commonTest/kotlin/dev/ulfrx/recipe/ui/screens/pantry/PantrySearchViewModelTest.kt` — mirror of recipes search VM test
|
|
- [ ] iOS-simulator smoke runbook captured in `02.1-VALIDATION.md` for tab back-stack + dock-collapse manual verification (UI-03/UI-04/UI-09/UI-10 visible checks)
|
|
- [ ] No new framework install needed — `kotlin.test` already in place.
|
|
|
|
**Honest note:** automated UI tests for `Compose Multiplatform on iOS` are not solved enough at this phase to be worth the cost. The shell is a shape that benefits from human eyes (animation feel, glass aesthetic, label legibility) more than from snapshot-asserting machinery. ViewModel state machines and pure helper functions are unit-testable; the visible chrome is verified by a manual smoke runbook. Phase 10 is the right place to revisit screenshot/UI testing once the shell stabilizes.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- [JetBrains: Navigation in Compose Multiplatform](https://kotlinlang.org/docs/multiplatform/compose-navigation.html) — official nav-compose guide; multi-back-stack pattern
|
|
- [Maven Central: navigation-compose 2.9.2](https://central.sonatype.com/artifact/org.jetbrains.androidx.navigation/navigation-compose/2.9.2)
|
|
- [Maven Central: io.github.fletchmckee.liquid:liquid](https://central.sonatype.com/artifact/io.github.fletchmckee.liquid/liquid) — version + iOS simulator artifact existence
|
|
- [GitHub: FletchMcKee/liquid](https://github.com/FletchMcKee/liquid) — public API: `liquid(state)`, `liquefiable(state)`, `rememberLiquidState()`
|
|
- [Compose Unstyled — Installation](https://composables.com/docs/com.composables/core/installation) — artifact `com.composables:composeunstyled:1.49.9`
|
|
- [Haze docs](https://chrisbanes.github.io/haze/) and [Haze 2.0 release post](https://chrisbanes.me/posts/haze-2.0/) — version state, platform support
|
|
- [Koin Compose docs](https://insert-koin.io/docs/reference/koin-compose/compose/) — `koinViewModel(viewModelStoreOwner = parent)` pattern
|
|
- `.planning/research/PITFALLS.md` — Pitfalls 1, 5, 12, 13 directly applicable
|
|
- `.planning/research/ARCHITECTURE.md` — Pattern 1 (StateFlow), package layout convention
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Saurabh Jadhav: Bottom Navigation + Nested Navigation Solved](https://saurabhjadhavblogs.com/jetpack-compose-bottom-navigation-nested-navigation-solved) — concrete `popUpTo + saveState` snippet (Android Jetpack docs; CMP port behaves equivalently per JetBrains guidance)
|
|
- [droidcon: Place Scope Handling on Auto-Pilot with Koin & Compose Navigation](https://www.droidcon.com/2024/10/16/place-scope-handling-on-auto-pilot-with-koin-compose-navigation/) — koin scope patterns with NavBackStackEntry
|
|
- [Medium: Liquid Glass Components in Compose Multiplatform (Part 1, MateeDevs)](https://medium.com/mateedevs/liquid-glass-components-in-compose-multiplatform-71b7a9ffc56d) — community usage examples
|
|
- [GitHub issue: Support saving state for nested NavHostController](https://github.com/JetBrains/compose-multiplatform/issues/4735) — historical context for nav state restoration on KMP
|
|
|
|
### Tertiary (LOW confidence — flagged for Wave-0 verification)
|
|
- Liquid library full target matrix (assumption A1 — confirmed by Maven Central listing of `liquid-iossimulatorarm64` artifact, but full README iOS-Arm64 device target list not retrieved)
|
|
- `Icons.Outlined.{MenuBook,Inventory2,CalendarMonth,ShoppingCart}` availability without `material-icons-extended` (assumption A2)
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH — every library is official, on Maven Central, with verified versions as of 2026-05-08
|
|
- Architecture (nested NavHost + Koin scoping): HIGH — JetBrains-documented pattern; Pitfall 13 codified; Pattern 2 is the canonical Koin recommendation
|
|
- Liquid integration specifics: MEDIUM — public API surface read from README; iOS klibs verified to exist on Maven Central but full device-target matrix not enumerated on package page (Wave-0 dependency-resolution check resolves this)
|
|
- Theme + token scaffold structure: HIGH — standard Compose `CompositionLocal` idiom; UI-SPEC pre-locked the shape
|
|
- Empty-state composable: HIGH — trivial; signature locked by D-13
|
|
- Search state machine: HIGH — pure ViewModel + StateFlow following Phase 2's established pattern
|
|
- Validation Architecture: MEDIUM — automated coverage of pure logic is solid; visible chrome relies on manual smoke given KMP iOS UI-test maturity
|
|
|
|
**Research date:** 2026-05-08
|
|
**Valid until:** 2026-06-07 (30 days; CMP / nav-compose / Liquid all on stable cadence with no upcoming breaking releases announced)
|