Compare commits

...

2 Commits

Author SHA1 Message Date
f7e866a08d Add preparing navigation to roadmap 2026-05-07 22:51:01 +02:00
95bbeb57d2 Simplify Lokksmith integration 2026-04-30 22:27:37 +02:00
58 changed files with 716 additions and 4358 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

@@ -1,6 +1,12 @@
dependencyResolutionManagement {
repositories {
google()
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}

View File

@@ -1,87 +1,22 @@
// Establishes the D-05 target matrix + JVM toolchain + warning policy.
// Android bytecode is JVM 11 (D-08); server + desktop + shared/jvm are JVM 21.
//
// This plugin is intentionally dependency-free: shared/ must stay light
// (no Koin, no Kermit), and composeApp adds those in its own build file.
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
plugins {
id("org.jetbrains.kotlin.multiplatform")
}
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
kotlin {
jvmToolchain(21)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
// Framework declaration moved here from composeApp/build.gradle.kts when the
// CocoaPods plugin was dropped (2026-04-28). The Xcode run script invokes
// :composeApp:embedAndSignAppleFrameworkForXcode, which needs `baseName` to
// resolve `import ComposeApp` from Swift. `isStatic = true` keeps the link
// shape unchanged from the previous CocoaPods setup. The `:shared` module is
// still re-exported so Swift can read shared constants when needed.
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
// `composeApp` only applies the multiplatform plugin; project deps
// live in its own build file. Skip the export when this convention
// plugin is applied to a module that doesn't depend on `:shared`
// (e.g., shared itself).
project.findProject(":shared")?.let { sharedProject ->
if (project != sharedProject) export(sharedProject)
}
}
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs { browser() }
compilerOptions {
allWarningsAsErrors.set(true)
}
sourceSets {
commonTest.dependencies {
implementation(libs.findLibrary("kotlin-test").get())
}
}
}
// Relax allWarningsAsErrors for KLIB-merging metadata tasks. KotlinCompileCommon
// aggregates dependency KLIBs and surfaces upstream "duplicated unique_name"
// resolver warnings caused by androidx.lifecycle 2.10.0 (Android-only) and
// org.jetbrains.androidx.lifecycle 2.10.0 (CMP) co-publishing artifacts with
// matching KLIB unique_names. This is an upstream Compose-Multiplatform 1.10 +
// lifecycle 2.10 ecosystem condition (KT-62515-style), not actionable in our
// source — so we keep -Werror on real source compilation tasks but disable it
// for the metadata-aggregation step where no user code is being compiled.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon>().configureEach {
// KMP metadata tasks can surface duplicate KLIB unique_name warnings from upstream
// Compose/AndroidX artifacts. Keep warnings-as-errors for source compilation, but
// do not fail metadata aggregation on dependency metadata warnings.
tasks.withType<KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(false)
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile>().configureEach {
if (name.endsWith("KotlinMetadata")) {
compilerOptions {
allWarningsAsErrors.set(false)
}
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
}
}

View File

@@ -18,23 +18,3 @@ spotless {
trimTrailingWhitespace()
}
}
// D-11 redundancy guard: if a module applies recipe.quality alongside a Kotlin plugin
// (multiplatform or jvm), ensure allWarningsAsErrors still applies even if the module
// build didn't already configure it. Guarded with plugins.withId so this plugin is
// safely composable even when applied alone (no KotlinCompilationTask type available
// on the classpath until a Kotlin plugin is present).
plugins.withId("org.jetbrains.kotlin.multiplatform") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(!name.endsWith("KotlinMetadata"))
}
}
}
plugins.withId("org.jetbrains.kotlin.jvm") {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach {
compilerOptions {
allWarningsAsErrors.set(true)
}
}
}

View File

@@ -1,9 +1,7 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
// this is necessary to avoid the plugins to be loaded multiple times in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinJvm) apply false

View File

@@ -1,18 +1,15 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
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.androidApplication)
id("recipe.kotlin.multiplatform")
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler)
id("recipe.quality")
}
// `group` is referenced by Compose Resources package naming — the
// `compose.resources { packageOfResClass }` block below pins the historical package
// regardless, but keep `group` set explicitly. Gradle artifact metadata only.
group = "dev.ulfrx.recipe"
version = "1.0.0"
@@ -57,8 +54,21 @@ android {
}
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
listOf(targets.getByName("iosArm64"), targets.getByName("iosSimulatorArm64")).forEach { target ->
(target as KotlinNativeTarget).binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(projects.shared)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.compose)
@@ -72,13 +82,7 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
// `api` so `:shared` types flow through to the exported ObjC
// framework headers when the iOS shell needs them.
api(projects.shared)
// Phase 2: Ktor client + serialization + secure settings (D-13, D-16, D-17).
// The MPP variant of `ktor-serialization-kotlinx-json` is required here; the
// server module keeps the `-jvm` variant via `libs.ktor.serializationKotlinxJson`.
implementation(libs.ktor.clientCore)
implementation(libs.ktor.clientAuth)
implementation(libs.ktor.clientContentNegotiation)
@@ -86,42 +90,23 @@ kotlin {
implementation(libs.ktor.serializationKotlinxJsonMpp)
implementation(libs.kotlinx.serializationJson)
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.lokksmith.compose)
}
commonTest.dependencies {
// 02-07: kotlinx.coroutines.test.runTest is the multiplatform-safe
// alternative to runBlocking (which is JVM/Native-only and breaks the
// wasmJs test target). All commonTest coroutine tests use it.
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutinesTest)
}
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android)
// Phase 2 Android: AndroidX Security Crypto for the SecureAuthStateStore
// actual (D-13). EncryptedSharedPreferences is accepted technical debt per
// Open Question #1; the Keystore-backed implementation can replace it
// without touching AuthSession.
implementation(libs.androidx.security.crypto)
implementation(libs.lokksmith.core)
implementation(libs.ktor.clientOkhttp)
}
iosMain.dependencies {
// Phase 2 iOS: Darwin engine for Ktor. Lokksmith handles the native
// Darwin engine for Ktor. Lokksmith handles the native
// ASWebAuthenticationSession integration directly from Kotlin.
implementation(libs.lokksmith.core)
implementation(libs.ktor.clientDarwin)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
// Phase 2 Desktop: CIO is the JVM Ktor engine for the dev-mode auth stub
// (D-02). The full stub lives in Plan 02-04; this just makes the engine
// available so `composeApp:run` still compiles in Phase 2.
implementation(libs.ktor.clientCio)
}
}
}
@@ -129,10 +114,16 @@ dependencies {
debugImplementation(libs.compose.uiTooling)
}
// `group = "dev.ulfrx.recipe"` shifts the Compose Resources `Res` class package from
// `recipe.composeapp.generated.resources` to `dev.ulfrx.recipe.composeapp.generated.resources`,
// breaking the Phase 1 `App.kt` import. Lock the historical package so module-naming
// changes don't cascade into UI code.
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

@@ -1,9 +1,23 @@
package dev.ulfrx.recipe.auth
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val androidAuthModule =
module {
single { createAndroidLokksmith(androidContext().applicationContext) }
single<Lokksmith> {
createLokksmith(androidContext().applicationContext).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}
}
single<Settings> {
val prefs = androidContext().applicationContext.getSharedPreferences("recipe_auth_state", Context.MODE_PRIVATE)
SharedPreferencesSettings(prefs)
}
}

View File

@@ -1,76 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import android.content.Intent
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.android.LokksmithAuthFlowActivity
import dev.lokksmith.createLokksmith
import org.koin.core.context.GlobalContext
actual class OidcClient {
private val context: Context
get() = GlobalContext.get().get<Context>().applicationContext
private val lokksmith: Lokksmith
get() = GlobalContext.get().get()
actual suspend fun login(): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
val initiation = flow.prepare()
context.startActivity(
LokksmithAuthFlowActivity
.createCustomTabsIntent(context = context, initiation = initiation)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
null -> {
runCatching { client.toOidcSuccess() }.getOrElse { error ->
OidcResult.AuthError(error.message ?: "OIDC login failed", error)
}
}
else -> {
failure
}
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
return OidcResult.AuthError("Stored Android auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
actual suspend fun logout(authStateJson: String) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching {
val initiation = flow.prepare()
context.startActivity(
LokksmithAuthFlowActivity
.createCustomTabsIntent(context = context, initiation = initiation)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
lokksmith.completeAuthFlow(client)
}
}
client.resetTokens()
}
}
fun createAndroidLokksmith(context: Context): Lokksmith =
createLokksmith(context).also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -1,50 +0,0 @@
@file:Suppress("DEPRECATION", "EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.koin.core.context.GlobalContext
actual class SecureAuthStateStore {
private val preferences by lazy {
val appContext = GlobalContext.get().get<Context>().applicationContext
val masterKey =
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// AndroidX Security Crypto is deprecated, but AUTH-02 explicitly requires
// EncryptedSharedPreferences in v1; this abstraction contains that debt.
EncryptedSharedPreferences.create(
appContext,
FILE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
actual fun read(): String? = preferences.getString(KEY_AUTH_STATE_JSON, null)
actual fun write(authStateJson: String) {
preferences
.edit()
.putString(KEY_AUTH_STATE_JSON, authStateJson)
.apply()
}
actual fun clear() {
preferences
.edit()
.remove(KEY_AUTH_STATE_JSON)
.apply()
}
private companion object {
const val FILE_NAME = "recipe_auth_state"
const val KEY_AUTH_STATE_JSON = "auth_state_json"
}
}

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,21 +40,22 @@ fun App() {
authSession.initialize()
}
when (val current = authState) {
AuthState.Loading -> {
when (authState) {
AuthState.Loading -> SplashScreen()
AuthState.Unauthenticated -> LoginScreen(viewModel = koinViewModel<LoginViewModel>())
AuthState.Authenticated -> {
val user = currentUser
if (user == null) {
SplashScreen()
}
AuthState.Unauthenticated -> {
LoginScreen(viewModel = koinViewModel<LoginViewModel>())
}
is AuthState.Authenticated -> {
} else {
PostLoginPlaceholderScreen(
user = current.user,
user = user,
viewModel = koinViewModel<PostLoginViewModel>(),
)
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
/**
* Bridges suspending OIDC orchestration ([OidcClient]) to Lokksmith's
* Compose-native launcher.
*
* Lokksmith owns the platform user-agent step (Custom Tabs / `ASWebAuthenticationSession`)
* via `rememberAuthFlowLauncher()`, which exposes its state as Compose `State`. To keep
* [AuthSession] / [LoginViewModel] callable as plain `suspend` functions, the screen
* wraps the Compose launcher in an [AuthBrowser] (see [ComposeAuthBrowser]) and hands
* it to the ViewModel. Result polling happens via `snapshotFlow`.
*
* Tests can fake this seam without touching Compose or Lokksmith.
*/
interface AuthBrowser {
suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result
}

View File

@@ -3,25 +3,23 @@ package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.ui.screens.auth.LoginViewModel
import dev.ulfrx.recipe.ui.screens.auth.PostLoginViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import org.koin.plugin.module.dsl.viewModel
val authModule =
module {
single<SecureAuthStateStore> { SecureAuthStateStore() }
single<OidcClient> { OidcClient() }
single<MeClient> { MeClient() }
single<SecureAuthStateStore> { SecureAuthStateStore(get()) }
single<OidcClient> { OidcClient(get()) }
single<AuthSession> {
AuthSession(
oidcClient = get<OidcClient>(),
store = get<SecureAuthStateStore>(),
meClient = get<MeClient>(),
)
}
single<HttpClient> { AuthHttpClient.create(get()) }
// Phase 2 auth-gate ViewModels (02-07). Registered here so the same module that
// owns AuthSession also owns its UI consumers; Koin scopes them per Composable.
viewModel { LoginViewModel(authSession = get()) }
viewModel { PostLoginViewModel(authSession = get()) }
viewModel<LoginViewModel>()
viewModel<PostLoginViewModel>()
}

View File

@@ -6,11 +6,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
interface OidcClientGateway {
suspend fun login(): OidcResult
suspend fun login(browser: AuthBrowser): OidcResult
suspend fun refresh(authStateJson: String): OidcResult
suspend fun logout(authStateJson: String)
suspend fun logout(authStateJson: String, browser: AuthBrowser)
}
interface AuthStateStore {
@@ -33,24 +33,27 @@ 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 {
override suspend fun login(): OidcResult = oidcClient.login()
override suspend fun login(browser: AuthBrowser): OidcResult = oidcClient.login(browser)
override suspend fun refresh(authStateJson: String): OidcResult = oidcClient.refresh(authStateJson)
override suspend fun logout(authStateJson: String) {
oidcClient.logout(authStateJson)
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
oidcClient.logout(authStateJson, browser)
}
},
store =
@@ -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,
@@ -92,10 +94,10 @@ class AuthSession(
}
}
suspend fun login(): AuthLoginResult =
when (val loginResult = oidcClient.login()) {
suspend fun login(browser: AuthBrowser): AuthLoginResult =
when (val loginResult = oidcClient.login(browser)) {
is OidcResult.Success -> {
authenticate(loginResult)
persistAndAuthenticate(loginResult)
AuthLoginResult.Success
}
@@ -115,11 +117,11 @@ class AuthSession(
}
}
suspend fun logout() {
suspend fun logout(browser: AuthBrowser) {
val storedJson = store.read()
if (!storedJson.isNullOrBlank()) {
runCatching {
oidcClient.logout(storedJson)
oidcClient.logout(storedJson, browser)
}
}
@@ -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

@@ -0,0 +1,27 @@
package dev.ulfrx.recipe.auth
import androidx.compose.runtime.snapshotFlow
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.compose.AuthFlowLauncher
import kotlinx.coroutines.flow.first
/**
* Adapter that converts Lokksmith's Compose-native [AuthFlowLauncher] (state-based)
* into a suspending [AuthBrowser] (one-shot await). The screen creates this once via
* `remember(launcher)` and passes it to the ViewModel, so call sites stay plain
* `suspend`-friendly.
*/
class ComposeAuthBrowser(
private val launcher: AuthFlowLauncher,
) : AuthBrowser {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result {
launcher.launch(initiation)
return snapshotFlow { launcher.result }
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
}
}

View File

@@ -2,24 +2,17 @@ package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal const val LOKKSMITH_AUTH_STATE_MARKER = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
@@ -27,7 +20,7 @@ internal suspend fun Lokksmith.recipeClient(): Client =
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
internal fun Client.recipeAuthorizationCodeFlow(): AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
@@ -35,49 +28,15 @@ internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
internal fun Client.recipeEndSessionFlow(): AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
authStateJson = LOKKSMITH_AUTH_STATE_MARKER,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,

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,24 +1,52 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
/**
* Common seam for Authentik OIDC.
* Common Authentik OIDC client built on Lokksmith.
*
* Native Android/iOS actuals use Lokksmith for browser-based Authorization Code
* + PKCE. Login requests must be public PKCE-compatible OIDC requests with
* exactly these scopes: `openid profile email offline_access` (D-06).
* Lokksmith owns state and nonce verification.
* Lokksmith owns PKCE, state, nonce, token storage, refresh, and end-session
* (D-06, D-16, D-19, D-20). This class only orchestrates: build the flow request
* and hand its [dev.lokksmith.client.request.flow.AuthFlow.Initiation] to the
* caller-supplied [AuthBrowser] (Lokksmith's `rememberAuthFlowLauncher` on
* mobile; a fake in tests), then map the terminal result.
*
* Refresh must go through Lokksmith fresh-token handling, then return an opaque
* auth-state marker for persistence (D-16). Logout must use RP-initiated
* end-session before local state is cleared; callers still clear local state if
* remote logout fails so users are never trapped in a stale session (D-19, D-20).
* Logout still clears local state if remote end-session fails so users are never
* trapped in a stale session.
*/
expect class OidcClient() {
suspend fun login(): OidcResult
class OidcClient(
private val lokksmith: Lokksmith,
) {
suspend fun login(browser: AuthBrowser): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
suspend fun refresh(authStateJson: String): OidcResult
return when (val failure = browser.launchAndAwait(flow.prepare()).toOidcFailureOrNull()) {
null ->
runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
suspend fun logout(authStateJson: String)
else -> failure
}
}
suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_MARKER) {
return OidcResult.AuthError("Stored auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
suspend fun logout(authStateJson: String, browser: AuthBrowser) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching { browser.launchAndAwait(flow.prepare()) }
}
client.resetTokens()
}
}

View File

@@ -1,19 +1,34 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.Settings
/**
* Persists the opaque platform auth state marker for the current app install.
* Persists the opaque auth-state marker that signals "this install has logged in".
*
* Mobile actuals must use explicit secure platform storage for token material
* (D-13): iOS Keychain and Android encrypted/Keystore-backed storage. Do not use
* no-arg or default insecure settings implementations for auth state. The stored
* value is global to the install and must be deleted on logout (D-15).
* The actual OIDC tokens (access, refresh, id) live in Lokksmith's own platform
* storage (Keychain on iOS, encrypted store on Android). This class only persists
* the literal marker constant ([LOKKSMITH_AUTH_STATE_MARKER]) so [AuthSession]
* can decide whether to attempt a silent refresh on cold start. Because the value
* is non-secret, plain key/value storage is sufficient.
*
* Platform [Settings] are wired in the platform Koin module:
* - Android: [com.russhwolf.settings.SharedPreferencesSettings]
* - iOS: [com.russhwolf.settings.KeychainSettings]
*/
expect class SecureAuthStateStore() {
fun read(): String?
class SecureAuthStateStore(
private val settings: Settings,
) {
fun read(): String? = settings.getStringOrNull(KEY)
fun write(authStateJson: String)
fun write(authStateJson: String) {
settings.putString(KEY, authStateJson)
}
fun clear()
fun clear() {
settings.remove(KEY)
}
private companion object {
const val KEY = "auth_state_marker"
}
}

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

@@ -17,7 +17,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -35,6 +38,8 @@ import recipe.composeapp.generated.resources.auth_sign_in_button
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
@@ -55,7 +60,7 @@ fun LoginScreen(viewModel: LoginViewModel) {
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { viewModel.onSignInClick() },
onClick = { viewModel.onSignInClick(browser) },
enabled = !state.isLoading,
) {
if (state.isLoading) {

View File

@@ -2,6 +2,7 @@ package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthLoginResult
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.Job
@@ -26,9 +27,9 @@ data class LoginScreenState(
)
/**
* Wraps [AuthSession] to drive the LoginScreen. Method-per-action: [onSignInClick] is the
* single entry point. Cancellation/network/unknown failures map to user-facing string
* resources per `02-UI-SPEC.md` § Copywriting Contract.
* Wraps [AuthSession] to drive the LoginScreen. The screen owns the
* Lokksmith [AuthBrowser] (via `rememberAuthFlowLauncher` + [dev.ulfrx.recipe.auth.ComposeAuthBrowser])
* and hands it in on click — the ViewModel never touches Compose or Lokksmith directly.
*
* Returns the launched [Job] from [onSignInClick] so tests can deterministically await
* completion without dragging a TestDispatcher into commonTest.
@@ -39,12 +40,12 @@ class LoginViewModel(
private val _state = MutableStateFlow(LoginScreenState())
val state: StateFlow<LoginScreenState> = _state.asStateFlow()
fun onSignInClick(): Job {
fun onSignInClick(browser: AuthBrowser): Job {
// Clear any previous inline error and enter the loading state before suspending —
// contract from UI-SPEC: tapping the button again clears stale error text.
_state.value = LoginScreenState(isLoading = true, errorKey = null)
return viewModelScope.launch {
val result = authSession.login()
val result = authSession.login(browser)
_state.value =
LoginScreenState(
isLoading = false,

View File

@@ -12,7 +12,10 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import dev.lokksmith.compose.rememberAuthFlowLauncher
import dev.ulfrx.recipe.auth.ComposeAuthBrowser
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -30,6 +33,8 @@ fun PostLoginPlaceholderScreen(
user: User,
viewModel: PostLoginViewModel,
) {
val launcher = rememberAuthFlowLauncher()
val browser = remember(launcher) { ComposeAuthBrowser(launcher) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
@@ -49,7 +54,7 @@ fun PostLoginPlaceholderScreen(
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = { viewModel.onSignOutClick() }) {
OutlinedButton(onClick = { viewModel.onSignOutClick(browser) }) {
Text(text = stringResource(Res.string.auth_sign_out_button))
}
}

View File

@@ -2,20 +2,22 @@ package dev.ulfrx.recipe.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.ulfrx.recipe.auth.AuthBrowser
import dev.ulfrx.recipe.auth.AuthSession
import kotlinx.coroutines.launch
/**
* Trivial in Phase 2 — exists so Phase 3's `HouseholdGate` follows the same VM pattern.
* Logout is silent (CONTEXT D-19): no confirmation modal; tap immediately initiates
* RP-initiated end-session via [AuthSession.logout].
* RP-initiated end-session via [AuthSession.logout]. The screen supplies the
* Lokksmith-backed [AuthBrowser].
*/
class PostLoginViewModel(
private val authSession: AuthSession,
) : ViewModel() {
fun onSignOutClick() {
fun onSignOutClick(browser: AuthBrowser) {
viewModelScope.launch {
authSession.logout()
authSession.logout(browser)
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import recipe.composeapp.generated.resources.Res
@@ -25,6 +26,7 @@ import recipe.composeapp.generated.resources.auth_app_name
* color flash.
*/
@Composable
@Preview
fun SplashScreen() {
Surface(
modifier = Modifier.fillMaxSize(),

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

@@ -1,6 +1,7 @@
package dev.ulfrx.recipe.auth
import dev.ulfrx.recipe.shared.dto.User
import dev.lokksmith.client.request.flow.AuthFlow
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -22,7 +23,7 @@ class AuthSessionTest {
}
@Test
fun successfulLoginWritesAuthStateJsonFetchesMeAndEmitsAuthenticatedWithNullHouseholdId() {
fun successfulLoginPersistsAuthStateAndEmitsAuthenticated() {
runTest {
val store = FakeAuthStateStore()
val oidcClient =
@@ -35,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()
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 =
@@ -63,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)
}
}
@@ -119,7 +112,7 @@ class AuthSessionTest {
val oidcClient = FakeOidcClient()
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
session.logout(NoopBrowser)
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
@@ -134,7 +127,7 @@ class AuthSessionTest {
val oidcClient = FakeOidcClient(logoutThrows = true)
val session = newSession(store = store, oidcClient = oidcClient)
session.logout()
session.logout(NoopBrowser)
assertEquals(listOf(AUTH_STATE_JSON), oidcClient.logoutCalls)
assertNull(store.value)
@@ -152,7 +145,7 @@ class AuthSessionTest {
oidcClient = FakeOidcClient(loginResult = OidcResult.Cancelled),
)
val result = session.login()
val result = session.login(NoopBrowser)
assertEquals(AuthLoginResult.Cancelled, result)
assertNull(store.value)
@@ -163,14 +156,17 @@ 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 {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
@@ -194,7 +190,7 @@ class AuthSessionTest {
val refreshCalls = mutableListOf<String>()
val logoutCalls = mutableListOf<String>()
override suspend fun login(): OidcResult {
override suspend fun login(browser: AuthBrowser): OidcResult {
loginCalls += Unit
return loginResult
}
@@ -204,7 +200,7 @@ class AuthSessionTest {
return refreshResult
}
override suspend fun logout(authStateJson: String) {
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {
logoutCalls += authStateJson
if (logoutThrows) {
error("end-session failed")
@@ -212,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

@@ -1,13 +1,49 @@
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
private class InMemorySettings : Settings {
private val map = mutableMapOf<String, Any>()
override val keys: Set<String> get() = map.keys
override val size: Int get() = map.size
override fun clear() = map.clear()
override fun remove(key: String) { map.remove(key) }
override fun hasKey(key: String): Boolean = map.containsKey(key)
override fun putInt(key: String, value: Int) { map[key] = value }
override fun getInt(key: String, defaultValue: Int): Int = (map[key] as? Int) ?: defaultValue
override fun getIntOrNull(key: String): Int? = map[key] as? Int
override fun putLong(key: String, value: Long) { map[key] = value }
override fun getLong(key: String, defaultValue: Long): Long = (map[key] as? Long) ?: defaultValue
override fun getLongOrNull(key: String): Long? = map[key] as? Long
override fun putString(key: String, value: String) { map[key] = value }
override fun getString(key: String, defaultValue: String): String = (map[key] as? String) ?: defaultValue
override fun getStringOrNull(key: String): String? = map[key] as? String
override fun putFloat(key: String, value: Float) { map[key] = value }
override fun getFloat(key: String, defaultValue: Float): Float = (map[key] as? Float) ?: defaultValue
override fun getFloatOrNull(key: String): Float? = map[key] as? Float
override fun putDouble(key: String, value: Double) { map[key] = value }
override fun getDouble(key: String, defaultValue: Double): Double = (map[key] as? Double) ?: defaultValue
override fun getDoubleOrNull(key: String): Double? = map[key] as? Double
override fun putBoolean(key: String, value: Boolean) { map[key] = value }
override fun getBoolean(key: String, defaultValue: Boolean): Boolean = (map[key] as? Boolean) ?: defaultValue
override fun getBooleanOrNull(key: String): Boolean? = map[key] as? Boolean
}
class SecureAuthStateStoreContractTest {
@Test
fun writeOverwritesPreviousValueAndReadReturnsLatest() {
val store = SecureAuthStateStore()
val store = SecureAuthStateStore(InMemorySettings())
store.write("""{"refresh_token":"first"}""")
store.write("""{"refresh_token":"second"}""")
@@ -17,7 +53,7 @@ class SecureAuthStateStoreContractTest {
@Test
fun clearRemovesStoredValue() {
val store = SecureAuthStateStore()
val store = SecureAuthStateStore(InMemorySettings())
store.write("""{"refresh_token":"stored"}""")
store.clear()

View File

@@ -1,11 +1,12 @@
package dev.ulfrx.recipe.ui.screens.auth
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.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
@@ -24,7 +25,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.Cancelled)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
@@ -36,7 +37,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.NetworkError)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_network, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
@@ -48,7 +49,7 @@ class LoginViewModelTest {
val session = newSession(loginResult = OidcResult.AuthError("token endpoint failed"))
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_unknown, viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
@@ -69,7 +70,7 @@ class LoginViewModelTest {
)
val viewModel = LoginViewModel(session)
viewModel.onSignInClick().join()
viewModel.onSignInClick(NoopBrowser).join()
assertNull(viewModel.state.value.errorKey)
assertEquals(false, viewModel.state.value.isLoading)
@@ -85,23 +86,24 @@ class LoginViewModelTest {
val queue = mutableListOf<OidcResult>(OidcResult.Cancelled)
val oidc =
object : OidcClientGateway {
override suspend fun login(): OidcResult = if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
override suspend fun login(browser: AuthBrowser): OidcResult =
if (queue.isNotEmpty()) queue.removeAt(0) else gate.await()
override suspend fun refresh(authStateJson: String): OidcResult = OidcResult.AuthError("not used")
override suspend fun logout(authStateJson: String) {}
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.
viewModel.onSignInClick().join()
viewModel.onSignInClick(NoopBrowser).join()
assertEquals(Res.string.auth_error_cancelled, viewModel.state.value.errorKey)
// Second attempt: launching the job sets loading=true + clears error
// BEFORE suspending. onSignInClick() does that synchronously before
// returning the launched Job, so we can assert immediately.
val job = viewModel.onSignInClick()
val job = viewModel.onSignInClick(NoopBrowser)
assertTrue(viewModel.state.value.isLoading)
assertNull(viewModel.state.value.errorKey)
@@ -116,14 +118,17 @@ 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 {
override suspend fun launchAndAwait(initiation: AuthFlow.Initiation): AuthFlowResultProvider.Result =
AuthFlowResultProvider.Result.Undefined
}
private class FakeAuthStateStore(
var value: String? = null,
) : AuthStateStore {
@@ -142,26 +147,10 @@ class LoginViewModelTest {
private val loginResult: OidcResult = OidcResult.AuthError("login not configured"),
private val refreshResult: OidcResult = OidcResult.AuthError("refresh not configured"),
) : OidcClientGateway {
override suspend fun login(): OidcResult = loginResult
override suspend fun login(browser: AuthBrowser): OidcResult = loginResult
override suspend fun refresh(authStateJson: String): OidcResult = refreshResult
override suspend fun logout(authStateJson: String) {}
}
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",
)
override suspend fun logout(authStateJson: String, browser: AuthBrowser) {}
}
}

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

@@ -1,8 +1,30 @@
@file:OptIn(
com.russhwolf.settings.ExperimentalSettingsApi::class,
com.russhwolf.settings.ExperimentalSettingsImplementation::class,
kotlinx.cinterop.ExperimentalForeignApi::class,
)
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.KeychainSettings
import com.russhwolf.settings.Settings
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import org.koin.dsl.module
import platform.Security.kSecAttrAccessible
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
val iosAuthModule =
module {
single { createIosLokksmith() }
single<Lokksmith> {
createLokksmith().also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}
}
single<Settings> {
KeychainSettings(
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
}
}

View File

@@ -1,98 +0,0 @@
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.client.Client
import dev.lokksmith.client.InternalClient
import dev.lokksmith.client.request.flow.AuthFlowResultProvider
import dev.lokksmith.client.request.flow.AuthFlowStateResponseHandler
import dev.lokksmith.client.request.flow.authorizationCode.AuthorizationCodeFlow
import dev.lokksmith.client.request.flow.endSession.EndSessionFlow
import dev.lokksmith.client.request.parameter.Scope
import dev.lokksmith.discoveryUrl
import dev.lokksmith.id
import dev.ulfrx.recipe.shared.Constants
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.selects.select
internal const val LOKKSMITH_CLIENT_KEY = "recipe-app"
internal const val LOKKSMITH_AUTH_STATE_JSON = "lokksmith:$LOKKSMITH_CLIENT_KEY"
internal suspend fun Lokksmith.recipeClient(): Client =
getOrCreate(LOKKSMITH_CLIENT_KEY) {
id = Constants.OIDC_CLIENT_ID
discoveryUrl = Constants.OIDC_ISSUER + ".well-known/openid-configuration"
}
internal fun Client.recipeAuthorizationCodeFlow(): dev.lokksmith.client.request.flow.AuthFlow =
authorizationCodeFlow(
AuthorizationCodeFlow.Request(
redirectUri = Constants.OIDC_REDIRECT_URI,
scope = setOf(Scope.Profile, Scope.Email, Scope.Custom("offline_access")),
),
)
internal fun Client.recipeEndSessionFlow(): dev.lokksmith.client.request.flow.AuthFlow? =
endSessionFlow(EndSessionFlow.Request(redirectUri = Constants.OIDC_REDIRECT_URI))
internal suspend fun Client.awaitTerminalAuthFlowResult(): AuthFlowResultProvider.Result =
AuthFlowResultProvider
.forClient(this)
.first { result ->
result is AuthFlowResultProvider.Result.Success ||
result is AuthFlowResultProvider.Result.Cancelled ||
result is AuthFlowResultProvider.Result.Error
}
internal suspend fun Lokksmith.completeAuthFlow(client: Client): AuthFlowResultProvider.Result =
coroutineScope {
val terminal = async { client.awaitTerminalAuthFlowResult() }
val responseUri =
async {
(client as InternalClient)
.snapshots
.map { snapshot -> snapshot.ephemeralFlowState?.responseUri }
.distinctUntilChanged()
.first { responseUri -> responseUri != null }
}
select<AuthFlowResultProvider.Result> {
terminal.onAwait { result ->
responseUri.cancel()
result
}
responseUri.onAwait { uri ->
terminal.cancel()
AuthFlowStateResponseHandler(this@completeAuthFlow).onResponse(checkNotNull(uri))
client.awaitTerminalAuthFlowResult()
}
}
}
internal suspend fun Client.toOidcSuccess(): OidcResult.Success {
var freshTokens: Client.Tokens? = null
runWithTokens { tokens -> freshTokens = tokens }
val tokens = checkNotNull(freshTokens) { "Lokksmith returned no tokens" }
return OidcResult.Success(
authStateJson = LOKKSMITH_AUTH_STATE_JSON,
accessToken = tokens.accessToken.token,
idToken = tokens.idToken.raw,
expiresAtEpochMillis = tokens.accessToken.expiresAt?.let { it * 1_000L } ?: 0L,
)
}
internal fun AuthFlowResultProvider.Result.toOidcFailureOrNull(): OidcResult? =
when (this) {
is AuthFlowResultProvider.Result.Success -> null
is AuthFlowResultProvider.Result.Cancelled -> OidcResult.Cancelled
is AuthFlowResultProvider.Result.Error -> OidcResult.AuthError(message ?: "OIDC flow failed")
AuthFlowResultProvider.Result.Undefined,
is AuthFlowResultProvider.Result.Processing,
-> OidcResult.AuthError("OIDC flow did not complete")
}

View File

@@ -1,55 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import dev.lokksmith.Lokksmith
import dev.lokksmith.SingletonLokksmithProvider
import dev.lokksmith.createLokksmith
import dev.lokksmith.ios.launchAuthFlow
import org.koin.mp.KoinPlatform
actual class OidcClient {
private val lokksmith: Lokksmith
get() = KoinPlatform.getKoin().get()
actual suspend fun login(): OidcResult {
val client = lokksmith.recipeClient()
val flow = client.recipeAuthorizationCodeFlow()
val initiation = flow.prepare()
lokksmith.launchAuthFlow(initiation)
return when (val failure = lokksmith.completeAuthFlow(client).toOidcFailureOrNull()) {
null -> runCatching { client.toOidcSuccess() }.getOrElse { OidcResult.AuthError(it.message ?: "OIDC login failed", it) }
else -> failure
}
}
actual suspend fun refresh(authStateJson: String): OidcResult {
if (authStateJson != LOKKSMITH_AUTH_STATE_JSON) {
return OidcResult.AuthError("Stored iOS auth state is not a Lokksmith session")
}
val client = lokksmith.recipeClient()
return runCatching { client.toOidcSuccess() }
.getOrElse { OidcResult.AuthError(it.message ?: "OIDC refresh failed", it) }
}
actual suspend fun logout(authStateJson: String) {
val client = lokksmith.recipeClient()
val flow = client.recipeEndSessionFlow()
if (flow != null) {
runCatching {
lokksmith.launchAuthFlow(flow.prepare())
lokksmith.completeAuthFlow(client)
}
}
client.resetTokens()
}
}
fun createIosLokksmith(): Lokksmith =
createLokksmith().also { lokksmith ->
SingletonLokksmithProvider.set(lokksmith)
}

View File

@@ -1,32 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.KeychainSettings
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Security.kSecAttrAccessible
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
@OptIn(ExperimentalSettingsApi::class, ExperimentalSettingsImplementation::class, ExperimentalForeignApi::class)
actual class SecureAuthStateStore {
private val settings =
KeychainSettings(
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
)
actual fun read(): String? = settings.getStringOrNull(AUTH_STATE_KEY)
actual fun write(authStateJson: String) {
settings.putString(AUTH_STATE_KEY, authStateJson)
}
actual fun clear() {
settings.remove(AUTH_STATE_KEY)
}
private companion object {
const val AUTH_STATE_KEY = "dev.ulfrx.recipe.auth.lokksmith-state"
}
}

View File

@@ -1,38 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult {
val token =
System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun refresh(authStateJson: String): OidcResult {
val token =
authStateJson.removePrefix("dev:").takeIf { it.isNotBlank() }
?: System.getenv(DEV_AUTH_TOKEN)
?: return OidcResult.AuthError("DEV_AUTH_TOKEN is not set")
return OidcResult.Success(
authStateJson = "dev:$token",
accessToken = token,
idToken = null,
expiresAtEpochMillis = Long.MAX_VALUE,
)
}
actual suspend fun logout(authStateJson: String) = Unit
private companion object {
const val DEV_AUTH_TOKEN = "DEV_AUTH_TOKEN"
}
}

View File

@@ -1,17 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class SecureAuthStateStore {
private var authStateJson: String? = null
actual fun read(): String? = authStateJson
actual fun write(authStateJson: String) {
this.authStateJson = authStateJson
}
actual fun clear() {
authStateJson = null
}
}

View File

@@ -1,19 +0,0 @@
package dev.ulfrx.recipe
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
fun main() {
configureLogging()
initKoin()
application {
Window(
onCloseRequest = ::exitApplication,
title = "recipe",
) {
App()
}
}
}

View File

@@ -1,11 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class OidcClient {
actual suspend fun login(): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun refresh(authStateJson: String): OidcResult = throw NotImplementedError("Wasm OIDC: v2")
actual suspend fun logout(authStateJson: String): Unit = throw NotImplementedError("Wasm OIDC: v2")
}

View File

@@ -1,17 +0,0 @@
@file:Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
package dev.ulfrx.recipe.auth
actual class SecureAuthStateStore {
private var authStateJson: String? = null
actual fun read(): String? = authStateJson
actual fun write(authStateJson: String) {
this.authStateJson = authStateJson
}
actual fun clear() {
authStateJson = null
}
}

View File

@@ -1,15 +0,0 @@
package dev.ulfrx.recipe
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import dev.ulfrx.recipe.di.initKoin
import dev.ulfrx.recipe.logging.configureLogging
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
configureLogging()
initKoin()
ComposeViewport {
App()
}
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>recipe</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 50 50" role="presentation">
<circle cx="25" cy="25" r="20" stroke="#ccc" stroke-width="4" fill="none"/>
<circle cx="25" cy="25" r="20" stroke="#333" stroke-width="4" fill="none" stroke-linecap="round"
stroke-dasharray="90 125">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>
<script type="application/javascript" src="composeApp.js"></script>
</body>
</html>

View File

@@ -1,7 +0,0 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

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,23 +1,17 @@
[versions]
agp = "8.11.2"
android-compileSdk = "36"
android-minSdk = "24"
android-minSdk = "33"
android-targetSdk = "36"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-security-crypto = "1.1.0"
androidx-testExt = "1.3.0"
composeHotReload = "1.0.0"
composeMultiplatform = "1.10.3"
exposed = "0.55.0"
flyway = "12.4.0"
hikari = "6.2.1"
junit = "4.13.2"
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"
@@ -32,15 +26,10 @@ testcontainers = "1.21.4"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
kotlin-testJunit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
# kotlinx.serialization (shared DTOs — D-27)
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
@@ -93,11 +82,9 @@ ktor-clientDarwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor
ktor-clientCio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-serializationKotlinxJsonMpp = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
# Phase 2 — Client: Lokksmith OIDC + Android secure storage + multiplatform-settings (D-01, D-13, AUTH-02)
lokksmith-core = { module = "dev.lokksmith:lokksmith-core", version.ref = "lokksmith" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" }
# Phase 2 — Client: Lokksmith OIDC (Compose integration pulls core transitively) + multiplatform-settings (D-01, D-13, AUTH-02)
lokksmith-compose = { module = "dev.lokksmith:lokksmith-compose", version.ref = "lokksmith" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
# Phase 2 — Server: Exposed DSL + Hikari (D-26)
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }
@@ -112,7 +99,6 @@ testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", ve
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
@@ -121,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")
}
@@ -17,6 +18,10 @@ kotlin {
}
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass.set("dev.ulfrx.recipe.ApplicationKt")
@@ -36,7 +41,6 @@ dependencies {
implementation(libs.postgresql)
implementation(projects.shared)
// Phase 2: Ktor auth + JWT validation + observability (D-21..D-23).
implementation(libs.ktor.serverAuth)
implementation(libs.ktor.serverAuthJwt)
implementation(libs.ktor.serverCallLogging)
@@ -49,10 +53,8 @@ dependencies {
implementation(libs.hikari)
testImplementation(libs.ktor.serverTestHost)
testImplementation(libs.kotlin.testJunit)
testImplementation(libs.kotlin.testJunit5)
// Phase 2: Testcontainers for JIT user provisioning + JWT auth integration tests
// (AUTH-03, AUTH-06). Wired here so Plan 02-02 only needs to write tests.
testImplementation(libs.testcontainers.postgresql)
testImplementation(libs.testcontainers.junit.jupiter)
}

View File

@@ -15,9 +15,11 @@ import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import org.flywaydb.core.Flyway
import org.junit.AfterClass
import org.junit.BeforeClass
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
@@ -34,16 +36,18 @@ import org.jetbrains.exposed.sql.Database as ExposedDatabase
* process before any test executes; Exposed is connected through Hikari to
* the container.
*/
@Testcontainers
class MeRouteTest {
companion object {
@Container
@JvmStatic
private val postgres = PostgreSQLContainer("postgres:16")
private lateinit var dataSource: HikariDataSource
private val json = Json { ignoreUnknownKeys = true }
@JvmStatic
@BeforeClass
@BeforeAll
fun setUpClass() {
postgres.start()
Flyway
.configure()
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
@@ -65,10 +69,9 @@ class MeRouteTest {
}
@JvmStatic
@AfterClass
@AfterAll
fun tearDownClass() {
dataSource.close()
postgres.stop()
}
}

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")
@@ -10,6 +11,11 @@ plugins {
kotlin {
explicitApi()
androidTarget()
iosArm64()
iosSimulatorArm64()
jvm()
// No iOS framework here — composeApp's umbrella `ComposeApp.framework`
// transitively exports shared. Producing a second framework would double-bundle
// the Kotlin stdlib at link time (PITFALL: duplicate-framework collision).
@@ -23,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()