Add preparing navigation to roadmap

This commit is contained in:
2026-05-07 22:51:01 +02:00
parent 95bbeb57d2
commit f7e866a08d
27 changed files with 402 additions and 3629 deletions

View File

@@ -2,7 +2,7 @@
## What This Is
A mobile-first meal planning app for a small household — pick recipes for the week, fill a calendar across five meal slots per day, and watch pantry gaps + shopping lists emerge from the plan. Kotlin Multiplatform targeting iOS primarily, with Android, Desktop, and Wasm as secondary targets. Built for me + my partner (shared household plan) with a handful of family/friends as authorized users on the same self-hosted backend.
A mobile-first meal planning app for a small household — pick recipes for the week, fill a calendar across five meal slots per day, and watch pantry gaps + shopping lists emerge from the plan. Kotlin Multiplatform targeting iOS primarily, with Android as the secondary app target and a JVM Ktor server. Built for me + my partner (shared household plan) with a handful of family/friends as authorized users on the same self-hosted backend.
## Core Value
@@ -60,6 +60,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
**Polish UI foundation**
- [ ] All user-facing strings are externalized into resource files (i18n-ready), even though v1 ships Polish only
- [ ] UI uses a Liquid-Glass-inspired visual language (translucent surfaces, blur, soft depth) implemented in Compose Multiplatform
- [ ] Signed-in users have a real app shell early: main menu/tab chrome, empty Planner / Recipes / Pantry / Shopping views, and a working search affordance before domain data arrives
- [ ] Visual hierarchy is less cramped than the mockup (more breathing room, calmer typography)
- [ ] iOS app feels iOS-idiomatic within Compose's constraints (tab bar placement, navigation patterns, safe areas, dark mode)
@@ -71,7 +72,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
- Nutrition goal tracking (targets, streaks, deficits) — *v1 shows numbers informationally only*
- English and other language copy — *code is i18n-ready but v1 ships Polish only*
- True native iOS 26 Liquid Glass via SwiftUI interop — *Compose approximation for v1; revisit only if real-device chrome feels clearly inadequate*
- Desktop and Wasm as shipped products — *Desktop useful for hot-reload dev; Wasm is a possible future target, neither is a v1 deliverable*
- Desktop and Wasm app targets — *removed from the v1 target matrix to keep the build focused on iOS, Android, and the JVM server*
- Sign in with Apple as a first-class button — *user's Authentik handles auth; Apple can be federated upstream in Authentik if needed later*
- Barcode scanning / receipt OCR for pantry updates — *manual entry is fine for a 2-person household*
- AI-generated recipes — *curated catalog is the value*
@@ -88,7 +89,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
## Context
**Codebase state.** The `~/dev/repo/recipe` directory is a freshly-generated Kotlin Multiplatform Compose template from IntelliJ with four modules: `composeApp` (Android + Desktop + iOS shared UI), `iosApp` (iOS bootstrap), `server` (Ktor, not yet written), and `shared` (common code). No app logic exists yet — this is effectively greenfield with the build infra in place.
**Codebase state.** The `~/dev/repo/recipe` directory has four modules: `composeApp` (Android + iOS shared UI), `iosApp` (iOS bootstrap), `server` (Ktor), and `shared` (common domain/DTO code). `shared` still has a JVM target because the server consumes it; `composeApp` does not ship Desktop or Wasm targets in v1.
**Reference implementation.** The user built a working PWA at `~/dev/repo/recipe-mockup/` (vanilla JS + Tailwind CDN + nginx/Docker). It implements the same four views (Recipe List, Meal Planner, Pantry, Shopping List) and has mature logic worth mining as a *functional* spec — particularly planner entry customization (substitutions, amount overrides, product selection), shortfall computation over a horizon, and shopping-list aggregation with "bought" session tracking. The mockup's UI design is **not** being carried forward; the user is redesigning visuals around a Liquid-Glass-inspired language.
@@ -96,9 +97,9 @@ A mobile-first meal planning app for a small household — pick recipes for the
**Infra.** User runs a homelab. Authentik is already installed. The Ktor backend will run on the same server (containerized). No managed cloud dependencies planned.
**Language & platform.** Polish-only UI for v1 (strings externalized for future i18n). iOS is the primary daily driver; Android deployed later for friends; Desktop useful for development (hot reload); Wasm is aspirational.
**Language & platform.** Polish-only UI for v1 (strings externalized for future i18n). iOS is the primary daily driver; Android deployed later for friends. Desktop and Wasm app targets are deferred out of v1.
**Liquid Glass decision.** True iOS 26 Liquid Glass (refractive material, specular highlights, morphing chrome) is a SwiftUI-native feature that Compose on iOS cannot reproduce exactly (Compose uses Skia, not Metal-native glass material). The v1 plan is: Compose-only approximation (blur + translucency + gradients) everywhere, measure real-device performance and visual quality, and **only** selectively add SwiftUI interop for the chrome (tab bar, nav bar) if the approximation feels insufficient. This avoids upfront interop complexity for 90%+ of the UI.
**Liquid Glass decision.** True iOS 26 Liquid Glass (refractive material, specular highlights, morphing chrome) is a SwiftUI-native feature that Compose on iOS cannot reproduce exactly (Compose uses Skia, not Metal-native glass material). The v1 plan is: Compose-only approximation using the Liquid library for menu/search/button chrome first, with blur/translucency fallbacks where needed; measure real-device performance and visual quality; and **only** selectively add SwiftUI interop for chrome if the approximation feels insufficient. This avoids upfront interop complexity for 90%+ of the UI.
## Constraints
@@ -117,12 +118,13 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| KMP with Compose Multiplatform for UI | iOS-primary + Android secondary + Desktop/Wasm optional; single codebase for 90%+ of UI | — Pending |
| KMP with Compose Multiplatform for UI | iOS-primary + Android secondary; Desktop/Wasm app targets removed from v1 to keep the build focused | — Pending |
| Household-sharing from day 1 (me + partner share one plan) | Core use case is cooking together; per-user + later-sharing would force data-model rewrite | — Pending |
| Authentik OIDC as sole auth provider for MVP | User already runs Authentik; self-hosted == aligned; Apple Sign-in likely not required for App Store since Authentik is user's own IdP, not a third-party social login | — Pending |
| Server lives on user's homelab alongside Authentik | Existing infra, zero managed-cloud cost, same ops surface | — Pending |
| Offline-first with last-write-wins sync | Grocery-store usage demands offline; conflict resolution overkill for a 2-person household | — Pending |
| Compose-only Liquid Glass approximation for v1 | Real iOS 26 Liquid Glass requires SwiftUI interop; approximation keeps single codebase; revisit only if chrome feels inadequate on real device | — Pending |
| Compose-only Liquid Glass approximation for v1 | Real iOS 26 Liquid Glass requires SwiftUI interop; Liquid gives Compose chrome/buttons a closer approximation while keeping a single codebase; revisit only if chrome feels inadequate on real device | — Pending |
| Real app shell before household/domain work | The authenticated app should stop feeling like a placeholder before Phase 3; menu navigation, empty states, and search can be built without household data and will reduce UI churn in later phases | — Pending |
| Polish-only strings, i18n-ready infrastructure | Single-language content for v1 speed; externalized strings prevent future rewrite | — Pending |
| Start catalog fresh (don't port mockup seed data) | Mockup data is a reference, not production content; user wants to re-curate | — Pending |
| Nutrition is informational only in v1 | Keep scope tight; tracking/goals are a natural v2 if usage patterns justify | — Pending |
@@ -132,7 +134,8 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Navigation: Jetpack Navigation Compose (CMP port) — `org.jetbrains.androidx.navigation:navigation-compose:2.9.x` | JetBrains-official recommendation on kotlinlang.org; type-safe routes via `@Serializable`; works across Android/iOS/Desktop/Wasm; skill transferable to Android | — Pending |
| Navigation: Jetpack Navigation Compose (CMP port) — `org.jetbrains.androidx.navigation:navigation-compose:2.9.x` | JetBrains-official recommendation on kotlinlang.org; type-safe routes via `@Serializable`; works across Android/iOS; skill transferable to Android | — Pending |
| Component foundation: Composables / Compose Unstyled | Use renderless, accessible primitives from `composables.com` for new shared controls so Recipe owns the visual language instead of inheriting Material 3's Android look. Composables One is optional only if the project has/chooses the paid kit. | — Pending |
| Architecture: ViewModel + StateFlow + method-per-action | Standard modern pattern; matches JetBrains/Google samples; lowest ceremony; upgrade individual screens to sealed-event onEvent only when they grow complex | — Pending |
| DI: Koin — `koin-core`, `koin-compose`, `koin-compose-viewmodel` | De facto KMP standard; smoothest `koinViewModel()` integration with Jetpack Nav back-stack scoping; no codegen; small surface to learn | — Pending |
| Local DB: SQLDelight 2.x | Most mature KMP DB; Wasm-ready (hedge for future Compose-for-Web target); raw SQL is a transferable skill; clear migration story via .sq files | — Pending |
@@ -142,7 +145,7 @@ A mobile-first meal planning app for a small household — pick recipes for the
| Logging: Kermit (Touchlab) | KMP-native logger; simple API; optional Crashlytics/Sentry bridges | — Pending |
| Image loading: Coil 3 (`io.coil-kt.coil3:coil-compose`) | First-class Compose Multiplatform support; modern API | — Pending |
| Key-value settings: `com.russhwolf:multiplatform-settings` | Small prefs (last tab, theme toggles) that don't belong in SQLDelight | — Pending |
| Glass/blur effects: Haze (`dev.chrisbanes.haze:haze`) | Purpose-built for glass UI in CMP; handles content capture + efficient re-blur; multiplatform | — Pending |
| Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for chrome/buttons; Haze only as a fallback/simple blur tool if needed | Liquid lets modifier nodes sample/manipulate pixels behind controls for a closer Liquid-Glass-style effect in Compose Multiplatform; keep effects constrained to chrome/buttons and verify on real iOS hardware | — Pending |
| Mobile OIDC: Lokksmith on Android/iOS, exposed via the KMP `OidcClient` interface | Keeps OIDC browser flow, PKCE, state/nonce, refresh, and end-session in Kotlin Multiplatform; removes the Swift AppAuth bridge and SwiftPM package from the iOS shell while still using ASWebAuthenticationSession underneath on iOS | — Pending |
### Server tech stack

View File

@@ -87,12 +87,13 @@
- [ ] **UI-01**: All user-facing strings are externalized as Compose resources (i18n-ready), even though v1 ships Polish only
- [ ] **UI-02**: App ships with Polish-language copy throughout
- [ ] **UI-03**: Bottom tab navigation with 4 tabs: Przepisy / Planer / Spiżarnia / Zakupy, each preserving its own back stack independently
- [ ] **UI-04**: Tab bar and nav bar use Haze-based glass/blur effects (Liquid Glass approximation)
- [ ] **UI-04**: App chrome and primary icon buttons use the chosen Compose Liquid-Glass approximation, starting with the Liquid library for menu/search controls
- [ ] **UI-05**: App supports light and dark color schemes with translucent surfaces working in both
- [ ] **UI-06**: UI is iOS-idiomatic within Compose constraints (safe areas, swipe-back gesture where applicable, keyboard avoidance)
- [ ] **UI-07**: Visual hierarchy is less cramped than the mockup — deliberate spacing, calmer typography, readable at arm's length
- [ ] **UI-08**: Locale-aware date formatting for display (days, months, weekday names in Polish); sync wire-format stays UTC ISO-8601
- [ ] **UI-09**: App starts cleanly on first launch (no blank flash) and shows appropriate empty states when catalog / plan / pantry / shopping are empty
- [ ] **UI-10**: Main app search affordance is functional before catalog data exists: search opens, query state updates, clear/close actions work, and the no-results/empty-data state is deliberate
### Infrastructure & build
@@ -217,13 +218,14 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
| SYNC-10 | Phase 4: Sync Engine Skeleton | Pending |
| UI-01 | Phase 11: Localization & iOS Deployment | Pending |
| UI-02 | Phase 11: Localization & iOS Deployment | Pending |
| UI-03 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-04 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-03 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
| UI-04 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
| UI-05 | Phase 5: Recipe Catalog (Read Path) | Pending |
| UI-06 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-07 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-06 | Phase 10: UI Chrome & Liquid-Glass Polish | Pending |
| UI-07 | Phase 10: UI Chrome & Liquid-Glass Polish | Pending |
| UI-08 | Phase 5: Recipe Catalog (Read Path) | Pending |
| UI-09 | Phase 10: UI Chrome & Haze Liquid-Glass Polish | Pending |
| UI-09 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
| UI-10 | Phase 2.1: App Shell, Navigation & Search Foundation | Pending |
| INFRA-01 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-02 | Phase 1: Project Infrastructure & Module Wiring | Complete |
| INFRA-03 | Phase 1: Project Infrastructure & Module Wiring | Complete |
@@ -233,8 +235,8 @@ Populated during roadmap creation. Each v1 requirement maps to exactly one phase
| INFRA-07 | Phase 11: Localization & iOS Deployment | Pending |
**Coverage:**
- v1 requirements: **72 total** (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
- Mapped to phases: **72**
- v1 requirements: **73 total** (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=10, INFRA=7)
- Mapped to phases: **73**
- Unmapped: **0**
---

View File

@@ -2,14 +2,15 @@
**Core Value:** "My week is planned." I pick recipes, the calendar fills up, and I know what we're eating.
**Granularity:** Fine (11 phases)
**Granularity:** Fine (11 phases + 1 inserted shell phase)
**Mode:** YOLO
**Source of truth:** Derived from `.planning/REQUIREMENTS.md` (72 v1 requirements) guided by `.planning/research/SUMMARY.md` (suggested skeleton) and `.planning/research/ARCHITECTURE.md` (build-order reasoning).
**Source of truth:** Derived from `.planning/REQUIREMENTS.md` (73 v1 requirements) guided by `.planning/research/SUMMARY.md` (suggested skeleton) and `.planning/research/ARCHITECTURE.md` (build-order reasoning).
## Phases
- [x] **Phase 1: Project Infrastructure & Module Wiring** — Running-but-empty KMP client + Ktor server with all build infra baked in
- [ ] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
- [x] **Phase 2: Authentication Foundation** — User signs in through Authentik (OIDC+PKCE) and the server validates tokens
- [ ] **Phase 2.1: App Shell, Navigation & Search Foundation** — Signed-in users can move between the four empty app areas through a Liquid-styled menu and open the search surface
- [ ] **Phase 3: Households, Membership & Server Data Foundation** — Users create/join households; server enforces household scope
- [ ] **Phase 4: Sync Engine Skeleton** — Offline-first read/write with outbox-backed LWW sync on a sentinel table
- [ ] **Phase 5: Recipe Catalog (Read Path)** — User browses, filters, and opens recipe details from a seeded catalog
@@ -17,7 +18,7 @@
- [ ] **Phase 7: Meal Planner — Customization & Nutrition** — User tweaks servings/ingredients/products per meal entry and sees daily nutrition
- [ ] **Phase 8: Pantry** — User tracks what's on hand and sees shortfalls against the plan
- [ ] **Phase 9: Shopping List & Session Log** — User generates a grouped shopping list from the plan and shops with "bought" tracking
- [ ] **Phase 10: UI Chrome & Haze Liquid-Glass Polish**Tab/nav glass effects, iOS-idiomatic chrome, calmer visual hierarchy
- [ ] **Phase 10: UI Chrome & Liquid-Glass Polish**Real-device Liquid glass tuning, iOS-idiomatic chrome, calmer visual hierarchy
- [ ] **Phase 11: Localization & iOS Deployment** — Full Polish copy pass, i18n-ready resources, TestFlight to partner
## Phase Summary Table
@@ -26,6 +27,7 @@
|---|------|-----------------|--------------|-----|
| 1 | Project Infrastructure & Module Wiring | KMP client + Ktor server build cleanly with convention plugins, version catalog, iOS binary flags, and a shared DTO module | INFRA-01, INFRA-02, INFRA-03, INFRA-06 | 4 |
| 2 | Authentication Foundation | End-to-end OIDC+PKCE login to Authentik with JIT user provisioning and server-side JWT validation | AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06 | 5 |
| 2.1 | App Shell, Navigation & Search Foundation | Signed-in users land in the real 4-tab app shell with empty Planner / Recipes / Pantry / Shopping screens, Liquid-styled chrome, and an operational search affordance | UI-03, UI-04, UI-09, UI-10 | 5 |
| 3 | Households, Membership & Server Data Foundation | Create/join households via invites; every request carries a household-scoped principal derived from JWT | HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05 | 5 |
| 4 | Sync Engine Skeleton | Outbox-backed LWW sync works round-trip on a sentinel table with server-assigned timestamps and cursor pull | SYNC-01, SYNC-02, SYNC-03, SYNC-04, SYNC-05, SYNC-06, SYNC-07, SYNC-08, SYNC-09, SYNC-10 | 5 |
| 5 | Recipe Catalog (Read Path) | User browses a seeded recipe catalog, filters/searches, and opens a detail view — offline-capable | RCPE-01, RCPE-02, RCPE-03, RCPE-04, RCPE-05, RCPE-06, RCPE-07, RCPE-08, UI-05, UI-08 | 5 |
@@ -33,7 +35,7 @@
| 7 | Meal Planner — Customization & Nutrition | User customizes ingredients per meal entry and sees daily macro totals that respect customizations | PLAN-07, PLAN-08, PLAN-09, PLAN-10, PLAN-11, PLAN-13 | 4 |
| 8 | Pantry | User manages pantry inventory by category and sees shortfalls for a chosen horizon | PNTR-01, PNTR-02, PNTR-03, PNTR-04, PNTR-05 | 4 |
| 9 | Shopping List & Session Log | User generates a category-grouped shopping list and marks items bought during a session | SHOP-01, SHOP-02, SHOP-03, SHOP-04, SHOP-05, SHOP-06 | 4 |
| 10 | UI Chrome & Haze Liquid-Glass Polish | 4-tab nav with independent back stacks, Haze glass chrome, iOS idioms, breathing-room visual hierarchy | UI-03, UI-04, UI-06, UI-07, UI-09 | 5 |
| 10 | UI Chrome & Liquid-Glass Polish | Real-device tuning for Liquid glass chrome, iOS idioms, breathing-room visual hierarchy, and cross-screen polish after real data exists | UI-06, UI-07 | 5 |
| 11 | Localization & iOS Deployment | All strings externalized, Polish copy throughout, partner installs via TestFlight | UI-01, UI-02, INFRA-04, INFRA-07 | 4 |
## Phase Details
@@ -78,18 +80,33 @@ Plans:
Plans:
- [x] 02-01-PLAN.md — Shared auth contracts, dependency aliases, Authentik setup docs, and source audit
- [x] 02-02-PLAN.md — Server JWT validation, JWKS hardening, JIT users, and `/api/v1/me`
- [ ] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [ ] 02-04-PLAN.md — Android OIDC actual, Android secure AuthState store, and manifest callback
- [ ] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
- [ ] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [ ] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
- [x] 02-03-PLAN.md — Common OIDC/store contracts, JVM/Wasm actuals, and store contract test
- [x] 02-04-PLAN.md — Android OIDC actual, Android secure AuthState store, and manifest callback
- [x] 02-05-PLAN.md — iOS OIDC actual, iOS Keychain store, and URL scheme callback
- [x] 02-06-PLAN.md — AuthSession state machine, bearer HTTP client, refresh/logout behavior, and Koin wiring
- [x] 02-07-PLAN.md — Compose auth gate UI, Polish resource strings, and iOS Authentik UAT
**UI hint:** yes
**Research flag:** yes
### Phase 2.1: App Shell, Navigation & Search Foundation
**Goal:** Replace the post-login placeholder with the real app shell before household/domain data lands: four persistent top-level destinations (Przepisy, Planer, Spiżarnia, Zakupy), deliberate empty states for each, a working search affordance, and the first shared component layer based on Composables + Liquid instead of growing further around Material 3.
**Depends on:** Phase 2
**Requirements:** UI-03, UI-04, UI-09, UI-10
**Success Criteria** (what must be TRUE):
1. After sign-in I land in the main app shell, not the Phase 2 welcome placeholder; I can switch between Przepisy, Planer, Spiżarnia, and Zakupy from the main menu without signing out.
2. Each tab has its own navigation state boundary from day 1, so future detail screens can preserve back stacks independently; the initial screens are intentionally empty states, not throwaway placeholders.
3. The shared UI foundation uses Composables' Compose Unstyled/renderless primitives for new controls where applicable, with local Recipe components providing the visual styling; Material 3 remains only as temporary legacy auth scaffold until migrated.
4. Menu chrome and primary icon buttons use the Liquid library (`io.github.fletchmckee.liquid:liquid`) for the first Liquid-Glass-inspired treatment, constrained to chrome/buttons and backed by a simple fallback path if performance or platform support is not acceptable.
5. The search button is functional: tapping it opens a search surface, query input updates state, close/clear actions work, and empty/no-data content is intentional until the recipe catalog read path wires real results in Phase 5.
**Plans:** TBD
**UI hint:** yes
**Research flag:** yes
### Phase 3: Households, Membership & Server Data Foundation
**Goal:** Introduce the tenancy model before any feature tables land — `households`, `memberships`, `invites` with Flyway migrations; server's `PrincipalResolver` maps JWT `sub` to an active `householdId`; client finishes onboarding by creating or joining a household.
**Depends on:** Phase 2
**Depends on:** Phase 2.1
**Requirements:** HSHD-01, HSHD-02, HSHD-03, HSHD-04, HSHD-05, HSHD-06, HSHD-07, INFRA-05
**Success Criteria** (what must be TRUE):
1. On my first login, I see an onboarding screen asking me to create a new household or enter an invite code.
@@ -188,17 +205,17 @@ Plans:
**UI hint:** yes
**Research flag:** no
### Phase 10: UI Chrome & Haze Liquid-Glass Polish
### Phase 10: UI Chrome & Liquid-Glass Polish
**Goal:** Swap the boring default chrome used in Phases 59 for the intended Liquid-Glass-inspired feel — 4-tab bottom nav with independent back stacks, Haze-based blur on tab/nav chrome, iOS-idiomatic safe-area/keyboard/swipe-back behaviors, and a calmer spacing/typography pass across every screen. Measurable against realistic data already present.
**Goal:** Polish and harden the app-wide visual system after real catalog/planner/pantry/shopping data exists — tune Liquid glass chrome on device, verify iOS idioms, remove remaining Material 3-looking surfaces, and run the calmer spacing/typography pass across every screen.
**Depends on:** Phase 9
**Requirements:** UI-03, UI-04, UI-06, UI-07, UI-09
**Requirements:** UI-06, UI-07
**Success Criteria** (what must be TRUE):
1. The app has a 4-tab bottom nav (Przepisy / Planer / Spiżarnia / Zakupy); tapping into a recipe detail, switching tabs, and coming back preserves the detail — each tab keeps its own back stack.
2. The tab bar and top nav bar use Haze-based translucent blur over content beneath them, consistent in light and dark schemes, and scrolling a full recipe grid on iPhone 11 stays above ~55 fps.
1. The Phase 2.1 app shell still preserves each tab's back stack after real recipe detail, planner, pantry, and shopping flows exist.
2. The tab bar, nav bar, and search/button chrome use the chosen Liquid-Glass approximation consistently in light and dark schemes, and scrolling a full recipe grid on iPhone 11 stays above ~55 fps.
3. The app respects iOS safe areas, supports the swipe-back gesture where applicable, and keyboards never cover focused inputs.
4. Typography and spacing feel noticeably calmer than the legacy PWA mockup — more whitespace between cards, larger hit targets, readable at arm's length.
5. On a fresh install I never see a blank flash on launch, and every main screen (catalog / planner / pantry / shopping) renders a deliberate empty state when there's nothing to show yet.
5. Any remaining Material 3-looking default components from earlier phases are replaced by Recipe-styled components built on the agreed component foundation.
**Plans:** TBD
**UI hint:** yes
**Research flag:** yes
@@ -222,7 +239,8 @@ Plans:
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Project Infrastructure & Module Wiring | 7/7 | Complete | 2026-04-24 |
| 2. Authentication Foundation | 2/7 | Executing | - |
| 2. Authentication Foundation | 7/7 | Complete | 2026-04-28 |
| 2.1 App Shell, Navigation & Search Foundation | 0/0 | Not started | - |
| 3. Households, Membership & Server Data Foundation | 0/0 | Not started | - |
| 4. Sync Engine Skeleton | 0/0 | Not started | - |
| 5. Recipe Catalog (Read Path) | 0/0 | Not started | - |
@@ -230,16 +248,16 @@ Plans:
| 7. Meal Planner — Customization & Nutrition | 0/0 | Not started | - |
| 8. Pantry | 0/0 | Not started | - |
| 9. Shopping List & Session Log | 0/0 | Not started | - |
| 10. UI Chrome & Haze Liquid-Glass Polish | 0/0 | Not started | - |
| 10. UI Chrome & Liquid-Glass Polish | 0/0 | Not started | - |
| 11. Localization & iOS Deployment | 0/0 | Not started | - |
## Coverage Summary
- **v1 requirements total:** 72 (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=9, INFRA=7)
- **Mapped to phases:** 72
- **v1 requirements total:** 73 (AUTH=6, HSHD=7, RCPE=8, PLAN=14, PNTR=5, SHOP=6, SYNC=10, UI=10, INFRA=7)
- **Mapped to phases:** 73
- **Unmapped:** 0
- **Coverage:** 100%
---
*Roadmap created: 2026-04-23*
*Granularity: fine (11 phases) | Mode: yolo*
*Granularity: fine (11 phases + inserted Phase 2.1) | Mode: yolo*

View File

@@ -2,15 +2,15 @@
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
current_plan: 7
status: executing
last_updated: "2026-04-28T14:57:40.504Z"
current_plan: 0
status: ready_to_plan
last_updated: "2026-05-07T00:00:00.000Z"
progress:
total_phases: 11
completed_phases: 1
total_phases: 12
completed_phases: 2
total_plans: 14
completed_plans: 13
percent: 93
completed_plans: 14
percent: 17
---
# Project State: Recipe
@@ -25,23 +25,23 @@ progress:
## Current Position
Phase: 02 (authentication-foundation) — EXECUTING
Plan: 7 of 7
**Current focus:** Phase 02 — authentication-foundation
**Current plan:** 7
**Status:** Ready to execute
**Phase progress:** 6 / 7 plans complete
**Progress bar:** `[█████████░] 93%`
Phase: 2.1 (app-shell-navigation-search-foundation) — READY TO PLAN
Plan: not planned yet
**Current focus:** Phase 2.1 — App Shell, Navigation & Search Foundation
**Current plan:** none
**Status:** Ready for detailed planning
**Phase progress:** 0 / 0 plans created
**Progress bar:** `[██░░░░░░░░] 17%`
## Performance Metrics
| Metric | Value |
|--------|-------|
| Phases planned | 11 |
| v1 requirements | 72 |
| Phases planned | 12 |
| v1 requirements | 73 |
| Coverage | 100% |
| Phases complete | 1 |
| Plans complete | 13 |
| Phases complete | 2 |
| Plans complete | 14 |
| Phase 02 P02 | 13min | 3 tasks | 14 files |
| Phase 02-authentication-foundation P03 | 31m | 2 tasks | 8 files |
| Phase 02-authentication-foundation P06 | 34m | 3 tasks | 7 files |
@@ -62,17 +62,19 @@ All locked tech-stack decisions are captured in `.planning/PROJECT.md § Key Dec
## Session Continuity
**Last session:** 2026-04-28T14:57:40.504Z
**Last session:** 2026-05-07T00:00:00.000Z
**Next action:** `/gsd-execute-phase 2` — Authentication Foundation plan 07.
**Next action:** `/gsd-discuss-phase 2.1` — App Shell, Navigation & Search Foundation, followed by `/gsd-plan-phase 2.1`.
**Research flags to revisit during future phase planning:**
- Phase 4 (SyncEngine): concrete cursor format, outbox schema ordering guarantees, retry/backoff policy.
- Phase 10 (UI chrome): current Haze CMP-iOS perf on iPhone 11/12-era hardware; liquid-glass approximation patterns.
- Phase 2.1 (App shell): validate current Composables / Compose Unstyled setup and Liquid `1.1.x` integration details before planning.
- Phase 10 (UI chrome): real-device Liquid glass performance on iPhone 11/12-era hardware after real data exists.
---
*Last updated: 2026-04-28*
*Last updated: 2026-05-07*
**Planned Phase:** 1 (Project Infrastructure & Module Wiring) — 7 plans — 2026-04-24T16:07:36.289Z
**Planned Phase:** 2 (Authentication Foundation) — 7 plans — 2026-04-28T08:30:48.000Z
**Inserted Phase:** 2.1 (App Shell, Navigation & Search Foundation) — planning pending — 2026-05-07

View File

@@ -15,7 +15,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
| File | What it is | When to read |
|------|-----------|--------------|
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
| `.planning/REQUIREMENTS.md` | 73 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
@@ -26,7 +26,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
**Client (`composeApp/`):**
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary)
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android secondary; Desktop/Wasm app targets removed from v1)
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
@@ -37,7 +37,8 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Logging: Kermit (Touchlab)
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Components: Composables / Compose Unstyled from `composables.com` for new shared controls; avoid expanding the app around Material 3
- Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for menu/search/button chrome; Haze only as a fallback/simple blur tool if needed
- Mobile OIDC: Lokksmith on Android/iOS (KMP interface; ASWebAuthenticationSession underneath on iOS)
**Server (`server/`):**
@@ -57,10 +58,10 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
```
recipe/
├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop)
├── composeApp/ # KMP mobile app: commonMain + androidMain + iosMain
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
├── server/ # Ktor + Exposed + Postgres + Flyway
├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB
├── shared/ # commonMain domain + DTOs; jvm target exists for server dependency
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
├── gradle/libs.versions.toml # Single source of truth for versions
└── .planning/ # GSD planning artifacts (see above)
@@ -72,8 +73,8 @@ dev.ulfrx.recipe/
├── app/ # App entry, Koin init, theme
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/
│ ├── theme/ # Colors, typography, Haze glass styles
│ ├── components/ # Shared composables
│ ├── theme/ # Colors, typography, Liquid glass style tokens
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting
@@ -92,14 +93,14 @@ dev.ulfrx.recipe/
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content.
10. **Liquid/glass effects on chrome only** (menu, tab/nav/search/button chrome), never over fast-scrolling content; Haze is fallback only.
## Current phase
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
**Build order (load-bearing — do not reorder):**
Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment.
Phase 1 Infra → Phase 2 Auth → Phase 2.1 App shell/navigation/search → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome polish → Phase 11 Localization + deployment.
## GSD commands you'll use

View File

@@ -15,7 +15,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
| File | What it is | When to read |
|------|-----------|--------------|
| `.planning/PROJECT.md` | Product scope, locked tech decisions, constraints, out-of-scope | Every session — source of truth |
| `.planning/REQUIREMENTS.md` | 72 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
| `.planning/REQUIREMENTS.md` | 73 v1 requirements with REQ-IDs grouped by category, plus v2 / out-of-scope | When touching any feature area |
| `.planning/ROADMAP.md` | 11 phases with goals, mapped requirements, success criteria | To know which phase we're in |
| `.planning/STATE.md` | Current phase + high-level pointer | Fast orientation |
| `.planning/config.json` | Workflow settings (YOLO mode, fine granularity, quality models) | Rarely — set once |
@@ -26,7 +26,7 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
## Tech stack (locked — see PROJECT.md § Key Decisions for full rationale)
**Client (`composeApp/`):**
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android, Desktop, Wasm secondary)
- Kotlin Multiplatform + Compose Multiplatform (iOS-primary; Android secondary; Desktop/Wasm app targets removed from v1)
- Navigation: `org.jetbrains.androidx.navigation:navigation-compose` (JetBrains-official CMP port of Jetpack Navigation)
- State: ViewModel + StateFlow, method-per-action; `org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`
- DI: Koin (`koin-core`, `koin-compose`, `koin-compose-viewmodel`)
@@ -37,7 +37,8 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
- Logging: Kermit (Touchlab)
- Images: Coil 3 (`io.coil-kt.coil3:coil-compose`)
- Settings/KV: `com.russhwolf:multiplatform-settings`
- Glass/blur: Haze (`dev.chrisbanes.haze:haze`)
- Components: Composables / Compose Unstyled from `composables.com` for new shared controls; avoid expanding the app around Material 3
- Glass effects: Liquid (`io.github.fletchmckee.liquid:liquid`) first for menu/search/button chrome; Haze only as a fallback/simple blur tool if needed
- Mobile OIDC: Lokksmith on Android/iOS through the KMP `OidcClient` interface. iOS uses ASWebAuthenticationSession underneath without a Swift auth bridge.
**Server (`server/`):**
@@ -57,10 +58,10 @@ This project uses GSD (Get Shit Done). All product scope, tech decisions, requir
```
recipe/
├── composeApp/ # KMP: commonMain + androidMain + iosMain + jvmMain (desktop)
├── composeApp/ # KMP mobile app: commonMain + androidMain + iosMain
├── iosApp/ # iOS bootstrap (Swift/SwiftUI thin shell)
├── server/ # Ktor + Exposed + Postgres + Flyway
├── shared/ # commonMain: domain + DTOs, no UI/HTTP/DB
├── shared/ # commonMain domain + DTOs; jvm target exists for server dependency
├── build-logic/ # Convention plugins (Kotlin/Compose/test config)
├── gradle/libs.versions.toml # Single source of truth for versions
└── .planning/ # GSD planning artifacts (see above)
@@ -72,8 +73,8 @@ dev.ulfrx.recipe/
├── app/ # App entry, Koin init, theme
├── navigation/ # NavHost, routes, nav graph (nested NavHosts per tab)
├── ui/
│ ├── theme/ # Colors, typography, Haze glass styles
│ ├── components/ # Shared composables
│ ├── theme/ # Colors, typography, Liquid glass style tokens
│ ├── components/ # Shared Recipe-styled composables built on Compose Unstyled where useful
│ └── screens/{recipes,planner,pantry,shopping}/ # Each with screen + ViewModel
├── data/{local,remote,repository}/
└── domain/ # Client-only logic; shared/ handles cross-cutting
@@ -92,14 +93,14 @@ dev.ulfrx.recipe/
7. **iOS binary flags on day 1:** `kotlin.native.binary.objcDisposeOnMain=false`, `kotlin.native.binary.gc=cms`.
8. **`shared/commonMain` stays light.** No Ktor, Compose, or SQLDelight imports.
9. **Strings externalized from day 1** — Polish-only content, but resources are multi-locale-ready.
10. **Haze blur on chrome only** (tab bar, nav bar), never over fast-scrolling content.
10. **Liquid/glass effects on chrome only** (menu, tab/nav/search/button chrome), never over fast-scrolling content; Haze is fallback only.
## Current phase
See `.planning/STATE.md`. The roadmap has 11 phases; you must work within the currently active one. Don't jump ahead.
**Build order (load-bearing — do not reorder):**
Phase 1 Infra → Phase 2 Auth → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome (Haze) → Phase 11 Localization + deployment.
Phase 1 Infra → Phase 2 Auth → Phase 2.1 App shell/navigation/search → Phase 3 Households → Phase 4 SyncEngine skeleton → Phase 5 Recipe catalog → Phase 6 Planner core → Phase 7 Planner customization/nutrition → Phase 8 Pantry → Phase 9 Shopping → Phase 10 UI chrome polish → Phase 11 Localization + deployment.
## GSD commands you'll use

View File

@@ -1,13 +1,11 @@
This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM), Server.
This is a Kotlin Multiplatform project targeting Android, iOS, and a JVM Ktor server.
* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications.
It contains several subfolders:
- [commonMain](./composeApp/src/commonMain/kotlin) is for code thats common for all targets.
- [commonMain](./composeApp/src/commonMain/kotlin) is for code that is common to the mobile app targets.
- [androidMain](./composeApp/src/androidMain/kotlin) contains Android-specific app code.
- [iosMain](./composeApp/src/iosMain/kotlin) contains iOS-specific app code.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name.
For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app,
the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls.
Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin)
folder is the appropriate location.
* [/iosApp](./iosApp/iosApp) contains iOS applications. Even if youre sharing your UI with Compose Multiplatform,
you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
@@ -15,8 +13,9 @@ This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM
* [/server](./server/src/main/kotlin) is for the Ktor server application.
* [/shared](./shared/src) is for the code that will be shared between all targets in the project.
The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). If preferred, you
can add code to the platform-specific folders here too.
The most important subfolder is [commonMain](./shared/src/commonMain/kotlin). `shared` still declares
a JVM target because the Ktor server depends on `projects.shared`; this does not mean the Compose
desktop application target is enabled.
### Build and Run Android Application
@@ -32,20 +31,6 @@ in your IDEs toolbar or build it directly from the terminal:
.\gradlew.bat :composeApp:assembleDebug
```
### Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget
in your IDEs toolbar or run it directly from the terminal:
- on macOS/Linux
```shell
./gradlew :composeApp:run
```
- on Windows
```shell
.\gradlew.bat :composeApp:run
```
### Build and Run Server
To build and run the development version of the server, use the run configuration from the run widget
@@ -60,21 +45,6 @@ in your IDEs toolbar or run it directly from the terminal:
.\gradlew.bat :server:run
```
### Build and Run Web Application
To build and run the development version of the web app, use the run configuration from the run widget
in your IDE's toolbar or run it directly from the terminal:
- for the Wasm target (faster, modern browsers):
- on macOS/Linux
```shell
./gradlew :composeApp:wasmJsBrowserDevelopmentRun
```
- on Windows
```shell
.\gradlew.bat :composeApp:wasmJsBrowserDevelopmentRun
```
### Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget
@@ -126,10 +96,5 @@ docker compose down -v
---
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),
[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform),
[Kotlin/Wasm](https://kotl.in/wasm/)…
We would appreciate your feedback on Compose/Web and Kotlin/Wasm in the public Slack
channel [#compose-web](https://slack-chats.kotlinlang.org/c/compose-web).
If you face any issues, please report them on [YouTrack](https://youtrack.jetbrains.com/newIssue?project=CMP).
Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)
and [Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform).

View File

@@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler)
id("recipe.quality")
}
@@ -116,3 +117,13 @@ dependencies {
compose.resources {
packageOfResClass = "recipe.composeapp.generated.resources"
}
// The Koin compiler plugin's strict graph check (default `compileSafety = true`) only
// validates types registered via the no-lambda `single<T>()` plugin DSL. Our DI graph
// includes factory-built types (Settings, Lokksmith, HttpClient) that must use the
// traditional `single<T> { ... }` form because they need custom construction. Disable
// the strict check so those lambda-registered types stop tripping false-positive
// "Missing dependency" errors. Runtime resolution is unchanged.
koinCompiler {
compileSafety = false
}

View File

@@ -13,20 +13,25 @@ import dev.ulfrx.recipe.ui.screens.auth.PostLoginPlaceholderScreen
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.SplashScreen
import dev.ulfrx.recipe.ui.theme.RecipeTheme
import dev.ulfrx.recipe.user.UserRepository
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Auth gate. Observes [AuthSession.state] and renders Splash / Login / PostLogin per
* `02-UI-SPEC.md` § Auth Gate Routing Contract. State changes drive recomposition;
* no manual navigation. Phase 3 replaces the `Authenticated` branch with `HouseholdGate`.
* Two-layer gate: [AuthSession] tells us whether tokens exist; [UserRepository]
* tells us who the authenticated principal is in the app's data model. While
* tokens are present but the `/me` fetch hasn't returned yet, we hold the
* splash so the user never sees an empty post-login screen. Phase 3 replaces
* the `Authenticated + user` branch with `HouseholdGate`.
*/
@Composable
@Preview
fun App() {
RecipeTheme {
val authSession = koinInject<AuthSession>()
val userRepository = koinInject<UserRepository>()
val authState by authSession.state.collectAsStateWithLifecycle()
val currentUser by userRepository.currentUser.collectAsStateWithLifecycle()
// Kick off the persisted-session restore once. AuthSession.initialize()
// refreshes the stored AuthState (or transitions to Unauthenticated on
@@ -35,20 +40,21 @@ fun App() {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
SplashScreen()
}
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
is AuthState.Authenticated -> {
PostLoginPlaceholderScreen(
user = current.user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
AuthState.Authenticated -> {
val user = currentUser
if (user == null) {
SplashScreen()
} else {
PostLoginPlaceholderScreen(
user = user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}
}

View File

@@ -4,15 +4,18 @@ import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.dsl.module
import org.koin.plugin.module.dsl.single
import org.koin.plugin.module.dsl.viewModel
val authModule =
module {
single<SecureAuthStateStore>()
single<OidcClient>()
single<MeClient>()
single<AuthSession>()
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
single<OidcClient> { OidcClient(get()) }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that

View File

@@ -33,15 +33,18 @@ sealed interface AuthLoginResult {
) : AuthLoginResult
}
/**
* Owns *just* the authentication state machine: tokens, refresh, logout.
* User profile fetch lives in [dev.ulfrx.recipe.user.UserRepository], which
* observes [state] and reacts to transitions.
*/
class AuthSession(
private val oidcClient: OidcClientGateway,
private val store: AuthStateStore,
private val meClient: MeGateway,
) {
constructor(
oidcClient: OidcClient,
store: SecureAuthStateStore,
meClient: MeClient,
) : this(
oidcClient =
object : OidcClientGateway {
@@ -65,7 +68,6 @@ class AuthSession(
store.clear()
}
},
meClient = meClient,
)
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
@@ -83,7 +85,7 @@ class AuthSession(
}
when (val refreshResult = oidcClient.refresh(storedJson)) {
is OidcResult.Success -> authenticate(refreshResult)
is OidcResult.Success -> persistAndAuthenticate(refreshResult)
OidcResult.Cancelled,
OidcResult.NetworkError,
@@ -95,7 +97,7 @@ class AuthSession(
suspend fun login(browser: AuthBrowser): AuthLoginResult =
when (val loginResult = oidcClient.login(browser)) {
is OidcResult.Success -> {
authenticate(loginResult)
persistAndAuthenticate(loginResult)
AuthLoginResult.Success
}
@@ -153,10 +155,9 @@ class AuthSession(
}
}
private suspend fun authenticate(result: OidcResult.Success) {
private fun persistAndAuthenticate(result: OidcResult.Success) {
persistTokens(result)
val user = meClient.getMe(result.accessToken)
_state.value = AuthState.Authenticated(user = user, householdId = null)
_state.value = AuthState.Authenticated
}
private fun persistTokens(result: OidcResult.Success) {

View File

@@ -1,16 +1,15 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
typealias HouseholdId = String
/**
* Pure authentication state — token-bearing or not. User profile (display name,
* email, server-issued id, household membership) lives behind
* [dev.ulfrx.recipe.user.UserRepository] and is loaded after auth flips to
* [Authenticated]. Screens that need a user observe both flows.
*/
sealed class AuthState {
data object Loading : AuthState()
data object Unauthenticated : AuthState()
data class Authenticated(
val user: User,
val householdId: HouseholdId? = null,
) : AuthState()
data object Authenticated : AuthState()
}

View File

@@ -1,41 +0,0 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.Constants
import dev.ulfrx.recipe.shared.dto.User
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
interface MeGateway {
suspend fun getMe(accessToken: String? = null): User
}
class MeClient(
private val httpClient: HttpClient =
HttpClient {
install(ContentNegotiation) {
json(authJson)
}
},
) : MeGateway {
override suspend fun getMe(accessToken: String?): User =
httpClient
.get("${Constants.API_BASE_URL}api/v1/me") {
if (!accessToken.isNullOrBlank()) {
header(HttpHeaders.Authorization, "Bearer ".plus(accessToken))
}
}.body<dev.ulfrx.recipe.shared.dto.MeResponse>()
.toUser()
private companion object {
val authJson =
Json {
ignoreUnknownKeys = true
}
}
}

View File

@@ -1,10 +1,11 @@
package dev.ulfrx.recipe.di
import dev.ulfrx.recipe.auth.authModule
import dev.ulfrx.recipe.user.userModule
import org.koin.dsl.module
// Phase 2 adds authModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
// Phase 2 adds authModule + userModule; Phase 4 adds syncModule; Phase 5 adds catalogModule; etc.
val appModule =
module {
includes(authModule)
includes(authModule, userModule)
}

View File

@@ -0,0 +1,23 @@
package dev.ulfrx.recipe.user
import dev.ulfrx.recipe.shared.Constants
import dev.ulfrx.recipe.shared.dto.MeResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import org.koin.dsl.module
val userModule =
module {
single<UserRepository> {
UserRepository(
authSession = get(),
fetchUser = {
get<HttpClient>()
.get("${Constants.API_BASE_URL}api/v1/me")
.body<MeResponse>()
.toUser()
},
)
}
}

View File

@@ -0,0 +1,57 @@
package dev.ulfrx.recipe.user
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthState
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Owns the current authenticated user as observable state.
*
* Subscribes to [AuthSession.state] for life: when auth flips to
* [AuthState.Authenticated] it fetches `/me` via [fetchUser] once and emits
* the result through [currentUser]. On [AuthState.Unauthenticated] it clears
* [currentUser] so screens drop back to the login gate cleanly.
*
* The fetch is a `suspend () -> User` lambda rather than a wrapped gateway:
* one consumer, one impl, no interface needed. When Phase 4 introduces a
* SyncEngine with local + remote sources, extract the seam then.
*/
class UserRepository(
private val authSession: AuthSession,
private val fetchUser: suspend () -> User,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
) {
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
init {
scope.launch {
authSession.state
.collect { state ->
when (state) {
is AuthState.Authenticated -> {
if (_currentUser.value == null) {
runCatching { fetchUser() }
.onSuccess { user -> _currentUser.value = user }
}
}
AuthState.Unauthenticated -> _currentUser.value = null
AuthState.Loading -> Unit
}
}
}
}
suspend fun refresh() {
runCatching { fetchUser() }
.onSuccess { user -> _currentUser.value = user }
}
}

View File

@@ -2,7 +2,6 @@ package dev.ulfrx.recipe.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -24,7 +23,7 @@ class AuthSessionTest {
}
@Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
runTest {
val store = FakeAuthStateStore()
val oidcClient =
@@ -37,22 +36,18 @@ class AuthSessionTest {
expiresAtEpochMillis = 123_456L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val session = newSession(store = store, oidcClient = oidcClient)
val result = session.login(NoopBrowser)
assertEquals(AuthLoginResult.Success, result)
assertEquals(AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
assertIs<AuthState.Authenticated>(session.state.value)
}
}
@Test
fun existingStoreRefreshesBeforeMeAndEmitsAuthenticatedWithoutLogin() {
fun existingStoreRefreshesAndEmitsAuthenticatedWithoutLogin() {
runTest {
val store = FakeAuthStateStore(value = "stored-auth-state-json")
val oidcClient =
@@ -65,18 +60,14 @@ class AuthSessionTest {
expiresAtEpochMillis = 789_000L,
),
)
val meClient = FakeMeClient(user = USER)
val session = newSession(store = store, oidcClient = oidcClient, meClient = meClient)
val session = newSession(store = store, oidcClient = oidcClient)
session.initialize()
assertEquals(emptyList(), oidcClient.loginCalls)
assertEquals(listOf("stored-auth-state-json"), oidcClient.refreshCalls)
assertEquals(REFRESHED_AUTH_STATE_JSON, store.value)
assertEquals(listOf<String?>(REFRESHED_ACCESS_TOKEN), meClient.accessTokens)
val authenticated = assertIs<AuthState.Authenticated>(session.state.value)
assertEquals(USER, authenticated.user)
assertNull(authenticated.householdId)
assertIs<AuthState.Authenticated>(session.state.value)
}
}
@@ -165,12 +156,10 @@ class AuthSessionTest {
private fun newSession(
store: AuthStateStore = FakeAuthStateStore(),
oidcClient: OidcClientGateway = FakeOidcClient(),
meClient: MeGateway = FakeMeClient(user = USER),
): AuthSession =
AuthSession(
oidcClient = oidcClient,
store = store,
meClient = meClient,
)
private object NoopBrowser : AuthBrowser {
@@ -219,29 +208,10 @@ class AuthSessionTest {
}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
val accessTokens = mutableListOf<String?>()
override suspend fun getMe(accessToken: String?): User {
accessTokens += accessToken
return user
}
}
private companion object {
const val AUTH_STATE_JSON = """{"refresh_token":"initial"}"""
const val REFRESHED_AUTH_STATE_JSON = """{"refresh_token":"refreshed"}"""
const val ACCESS_TOKEN = "access-token"
const val REFRESHED_ACCESS_TOKEN = "refreshed-access-token"
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -5,10 +5,8 @@ import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.MeGateway
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import recipe.composeapp.generated.resources.Res
@@ -95,7 +93,7 @@ class LoginViewModelTest {
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
val session = AuthSession(oidc, FakeAuthStateStore(), FakeMeClient(USER))
val session = AuthSession(oidc, FakeAuthStateStore())
val viewModel = LoginViewModel(session)
// First attempt: error seeded.
@@ -120,12 +118,10 @@ class LoginViewModelTest {
private fun newSession(
loginResult: OidcResult,
store: AuthStateStore = FakeAuthStateStore(),
meClient: MeGateway = FakeMeClient(USER),
): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = loginResult),
store = store,
meClient = meClient,
)
private object NoopBrowser : AuthBrowser {
@@ -157,20 +153,4 @@ class LoginViewModelTest {
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
private class FakeMeClient(
private val user: User,
) : MeGateway {
override suspend fun getMe(accessToken: String?): User = user
}
private companion object {
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -0,0 +1,128 @@
package dev.ulfrx.recipe.user
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession
import dev.ulfrx.recipe.auth.AuthStateStore
import dev.ulfrx.recipe.auth.OidcClientGateway
import dev.ulfrx.recipe.auth.OidcResult
import dev.ulfrx.recipe.shared.dto.User
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class UserRepositoryTest {
@Test
fun fetchesUserWhenAuthFlipsToAuthenticated() =
runTest {
val session = newSession()
var fetchCount = 0
val repository =
UserRepository(
authSession = session,
fetchUser = { fetchCount++; USER },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
val user = repository.currentUser.first { it != null }
assertEquals(USER, user)
assertEquals(1, fetchCount)
}
@Test
fun clearsUserOnLogout() =
runTest {
val session = newSession()
val repository =
UserRepository(
authSession = session,
fetchUser = { USER },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
repository.currentUser.first { it != null }
session.logout(NoopBrowser)
val cleared = repository.currentUser.firstOrNull { it == null }
assertNull(cleared)
}
@Test
fun networkFailureLeavesCurrentUserNullWithoutCrashing() =
runTest {
val session = newSession()
val repository =
UserRepository(
authSession = session,
fetchUser = { error("network down") },
scope = TestScope(testScheduler),
)
session.login(NoopBrowser)
testScheduler.advanceUntilIdle()
assertNull(repository.currentUser.value)
}
private fun newSession(): AuthSession =
AuthSession(
oidcClient = FakeOidcClient(loginResult = SUCCESS),
store = FakeAuthStateStore(),
)
private object NoopBrowser : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
override fun read(): String? = value
override fun write(authStateJson: String) {
value = authStateJson
}
override fun clear() {
value = null
}
}
private class FakeOidcClient(
private val loginResult: OidcResult,
) : OidcClientGateway {
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
private companion object {
val SUCCESS =
OidcResult.Success(
authStateJson = "{}",
accessToken = "access",
idToken = null,
expiresAtEpochMillis = 0L,
)
val USER =
User(
id = "00000000-0000-0000-0000-000000000001",
sub = "authentik-sub",
email = "user@example.invalid",
displayName = "Recipe User",
)
}
}

View File

@@ -183,8 +183,8 @@ plan number), ✂ explicitly deferred (see end of section).
| RESEARCH | Open Question resolved: Exposed `newSuspendedTransaction` import verified at impl time | ⤳ 02-02 |
| RESEARCH | Open Question resolved: Ktor patch version follows the selected auth client | ✅ Lokksmith requires Ktor 3.4.2 |
| CONTEXT | **D-01** Lokksmith on both mobile platforms via expect/actual `OidcClient` | ⤳ 02-03 (expect) + 02-04 (Android actual) + 02-05 (iOS actual) |
| CONTEXT | **D-02** JVM `actual` is `DEV_AUTH_TOKEN` env-var stub | ⤳ 02-03 |
| CONTEXT | **D-03** Wasm `actual` is `NotImplementedError("Wasm OIDC: v2")` | ⤳ 02-03 |
| CONTEXT | **D-02** Desktop/JVM app auth stub | Superseded: `composeApp` no longer has a JVM/Desktop target; `shared.jvm()` remains only for the server dependency |
| CONTEXT | **D-03** Wasm app auth stub | Superseded: `composeApp` no longer has a Wasm target in v1 |
| CONTEXT | **D-04** `OidcClient.login()` / `.refresh()` are `suspend` | ⤳ 02-03 |
| CONTEXT | **D-05** Public + PKCE S256 | ✅ Provider |
| CONTEXT | **D-06** scopes `openid profile email offline_access` | ✅ Scopes |
@@ -226,8 +226,8 @@ plan number), ✂ explicitly deferred (see end of section).
These are explicitly out of scope for v1 per `.planning/phases/02-authentication-foundation/02-CONTEXT.md` § Deferred Ideas. Listed here so the audit makes the exclusions traceable.
- **Universal Links / App Links** — excluded; rely on `recipe://callback` custom scheme. Revisit only if app gains broader distribution beyond the household or if Apple/Google deprecate custom-scheme OIDC redirects.
- **Real Desktop OIDC** — JVM target ships a `DEV_AUTH_TOKEN` env-var stub (D-02). Loopback-redirect implementation deferred until Desktop becomes a release surface.
- **Wasm OIDC implementation** — `wasmJs` actual throws `NotImplementedError`. Browser-redirect flow deferred until Wasm becomes a release surface.
- **Real Desktop OIDC** — no longer applicable in v1; the `composeApp` JVM/Desktop target was removed.
- **Wasm OIDC implementation** — no longer applicable in v1; the `composeApp` Wasm target was removed.
- **Apple Sign-in as a first-class button** — Authentik can federate Apple upstream if ever desired.
- **Authentik provisioning automation (Terraform/Ansible)** — this document is the manual reproduction playbook; automation deferred post-v1.
- **JWT validation tests against a real Authentik instance** — Phase 2 ships unit/integration tests with hand-crafted JWTs. Real-Authentik integration tests deferred to Phase 11 (deployment).

View File

@@ -1,7 +1,7 @@
[versions]
agp = "8.11.2"
android-compileSdk = "36"
android-minSdk = "24"
android-minSdk = "33"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-lifecycle = "2.10.0"
@@ -11,6 +11,7 @@ flyway = "12.4.0"
hikari = "6.2.1"
kermit = "2.1.0"
koin = "4.2.1"
koin-plugin = "1.0.0-RC2"
kotlin = "2.3.20"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.7.3"
@@ -106,3 +107,4 @@ ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
flywayPlugin = { id = "org.flywaydb.flyway", version.ref = "flyway" }
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }

View File

@@ -1,13 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
ws@8.18.3:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ktor)
alias(libs.plugins.flywayPlugin)
alias(libs.plugins.koin.compiler)
application
id("recipe.quality")
}

View File

@@ -2,6 +2,7 @@ plugins {
// AGP must apply BEFORE recipe.kotlin.multiplatform — the latter calls androidTarget(),
// which requires the Android Gradle Plugin to already be on the project.
alias(libs.plugins.androidLibrary)
alias(libs.plugins.koin.compiler)
id("recipe.kotlin.multiplatform")
alias(libs.plugins.kotlinSerialization)
id("recipe.quality")
@@ -28,6 +29,10 @@ kotlin {
// re-declaring it.
api(libs.kotlinx.serializationJson)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}

View File

@@ -1,7 +0,0 @@
package dev.ulfrx.recipe
public class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
}
public actual fun getPlatform(): Platform = JVMPlatform()

View File

@@ -1,7 +0,0 @@
package dev.ulfrx.recipe
public class WasmPlatform : Platform {
override val name: String = "Web with Kotlin/Wasm"
}
public actual fun getPlatform(): Platform = WasmPlatform()