From 9767b0d69910d08bd9753b96277ebe258161e632 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 07:36:39 +0200 Subject: [PATCH 01/15] chore: add design spec for in-app vacation home (fritidshus) purchase Single feature-purchase-house module, vacation home first, extra-buildings UI and SE_HOUSE form deferred to follow-up PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...21-in-app-vacation-home-purchase-design.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-in-app-vacation-home-purchase-design.md diff --git a/docs/superpowers/specs/2026-05-21-in-app-vacation-home-purchase-design.md b/docs/superpowers/specs/2026-05-21-in-app-vacation-home-purchase-design.md new file mode 100644 index 0000000000..5659fe9200 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-in-app-vacation-home-purchase-design.md @@ -0,0 +1,256 @@ +# In-app Vacation Home (Fritidshus) Purchase — Design Spec + +## Goal + +Add an in-app vacation home (`SE_VACATION_HOME`) insurance purchase flow, reusing the existing apartment/car purchase architecture. Introduce a forward-looking `feature-purchase-house` module that will later host the SE_HOUSE flow (added in a follow-up PR) as a sibling form composable. + +The flow mirrors the apartment/car shape: a product-specific form screen, then handoff to the shared `purchase-common` screens for tier selection, summary, BankID signing, and success/failure. + +## Base branch + +This work depends on the `purchase-common` module and the apartment/car flow patterns, which currently live on `feat/in-app-car-purchase`. This PR must branch from `feat/in-app-car-purchase` (or whichever branch the common module has been merged into). + +## Product names and tiers + +- Storefront `ProductName.SE_VACATION_HOME` maps to two quote types: `SE_VACATION_HOME_BAS` and `SE_VACATION_HOME_STANDARD`. +- A single `productName = "SE_VACATION_HOME"` is passed to `priceIntentCreate`. The backend returns both tier offers after `priceIntentConfirm`. Users pick between BAS and STANDARD on the shared `SelectTierDestination`. + +## Module structure + +### New module: `feature-purchase-house` + +``` +app/feature/feature-purchase-house/ +├── build.gradle.kts +└── src/main/ + ├── graphql/ + │ ├── HouseShopSessionCreateMutation.graphql + │ ├── HousePriceIntentCreateMutation.graphql + │ ├── HousePriceIntentDataUpdateMutation.graphql + │ ├── HousePriceIntentConfirmMutation.graphql + │ ├── HouseMemberContactInfoQuery.graphql + │ └── HouseProductOfferFragment.graphql + └── kotlin/com/hedvig/android/feature/purchase/house/ + ├── data/ + │ ├── HousePurchaseModels.kt + │ ├── CreateHouseSessionAndPriceIntentUseCase.kt + │ └── SubmitVacationHomeFormAndGetOffersUseCase.kt + ├── ui/vacationhome/ + │ ├── VacationHomeFormDestination.kt + │ └── VacationHomeFormViewModel.kt + ├── navigation/ + │ ├── HousePurchaseDestination.kt + │ └── HousePurchaseNavGraph.kt + └── di/HousePurchaseModule.kt +``` + +**Why `feature-purchase-house` not `feature-purchase-vacation-home`:** SE_HOUSE and SE_VACATION_HOME share ~60% of their racoon form fields (street, zip, livingSpace, yearOfConstruction, numberOfBathrooms, isSubleted, extraBuildings, email, ssn) and use the same Apollo mutations (only `productName` and form-data keys differ). Hosting both in one module shares the Apollo operations, use cases, nav graph, and DI scaffolding (~500 LOC of structural code) instead of duplicating them. Two separate form composables keep product-specific UI focused (no `if (product == X)` branching). + +**Apollo operation naming:** `House`-prefixed (e.g. `HousePriceIntentCreate`) to avoid Apollo Kotlin classpath conflicts with apartment's and car's identical operations. SDL is identical to the apartment/car versions; only the operation names differ. + +**Build config (`build.gradle.kts`):** +- Plugins: `hedvig.android.library`, `hedvig.gradle.plugin` +- Hedvig DSL: `apollo("octopus")`, `serialization()`, `compose()` +- Dependencies: mirror `feature-purchase-car` (purchase-common, core-common-public, core-resources, design-system-hedvig, navigation-compose, koin, apollo, etc.) + +### Modified modules + +#### `feature-insurances` + +`InsuranceGraph.kt` `onCrossSellClick`: + +- Add `onNavigateToHousePurchase: (productName: String) -> Unit` callback parameter. +- Route URLs containing `fritidshusforsakring` (sv) or `vacation-home` (en) to `onNavigateToHousePurchase("SE_VACATION_HOME")`. +- The fritidshus branch must come **before** the apartment branches so future SE_HOUSE URLs (`hemforsakring/villaforsakring`) don't get stolen by apartment's generic `hemforsakring`/`home-insurance` match. For SE_VACATION_HOME alone there is no substring conflict, but the ordering is defensive. + +#### `app` (main application module) + +- Register `housePurchaseModule` in `ApplicationModule`. +- Add `housePurchaseNavGraph(navController, popBackStack, finishApp, crossSellAfterFlowRepository)` to `HedvigNavHost`. +- Wire `onNavigateToHousePurchase = { productName -> navController.navigate(HousePurchaseGraphDestination(productName)) }` at the insurances graph callsite. + +## Module dependency graph + +``` +feature-purchase-apartment ──> purchase-common +feature-purchase-car ───────> purchase-common +feature-purchase-house ─────> purchase-common [new] +app ──> feature-purchase-apartment +app ──> feature-purchase-car +app ──> feature-purchase-house [new] +app ──> purchase-common +``` + +Feature modules continue not to depend on each other; all share `purchase-common` (a library module). + +## Data flow + +``` +1. User taps fritidshus cross-sell in insurances tab +2. InsuranceGraph routes "fritidshusforsakring" / "vacation-home" URL + → onNavigateToHousePurchase("SE_VACATION_HOME") +3. Navigate to HousePurchaseGraphDestination(productName = "SE_VACATION_HOME") +4. VacationHomeFormDestination loads: + a. CreateHouseSessionAndPriceIntentUseCase(productName): + - HouseShopSessionCreate(CountryCode.SE) → shopSessionId + - HousePriceIntentCreate(shopSessionId, productName) → priceIntentId + - HouseMemberContactInfo() → ssn, email + b. User fills 8 form fields (see below) + c. SubmitVacationHomeFormAndGetOffersUseCase(priceIntentId, formMap): + - HousePriceIntentDataUpdate(priceIntentId, data) — see form-data keys below + - HousePriceIntentConfirm(priceIntentId) → list of ProductOffer + - Map each offer to HouseTierOffer (uses HouseProductOfferFragment) +5. SelectTierDestination (purchase-common): user picks BAS vs STANDARD +6. PurchaseSummaryDestination (purchase-common): review selected tier +7. SigningDestination (purchase-common): BankID polling + QR fallback +8. PurchaseSuccessDestination (purchase-common): confirmation +``` + +## V1 form fields (single scrolling screen) + +| # | Field | Compose component | Validation | Form-data key | Type sent | +|---|-------|-------------------|------------|---------------|-----------| +| 1 | Street | `HedvigTextField` | non-empty | `street` | string | +| 2 | Zip code | `HedvigTextField` (numeric, max length 5) | exactly 5 digits | `zipCode` | string | +| 3 | Multiple owners | Radio pair (`Ja`/`Nej`) | required selection | `multipleOwners` | boolean | +| 4 | Year of construction | `HedvigTextField` (numeric) | 1700–current year inclusive | `yearOfConstruction` | int | +| 5 | Living space (m²) | `HedvigTextField` (numeric) | > 0 | `livingSpace` | int | +| 6 | Water connected | Radio pair (`Ja`/`Nej`) | required selection | `hasWaterConnected` | boolean | +| 7 | Number of bathrooms | `HedvigStepper` (1–10, default 1) | stepper-bounded | `numberOfBathrooms` | int | +| 8 | Subleted | Radio pair (`Ja`/`Nej`) | required selection | `isSubleted` | boolean | + +**Auto-injected (never shown in the form):** +- `ssn` — fetched from `currentMember.ssn` during session creation; fail-fast with `ErrorMessage()` if null +- `email` — fetched from `currentMember.email` +- `extraBuildings` — sent as empty array `[]` in V1 (UI deferred, see scope) + +**Form errors** surface as field-level `errorState` on the input, or as a top-level `ErrorDialog` for submit-level errors (matches the car form pattern). + +## Domain models + +```kotlin +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, + val ssn: String, + val email: String, +) + +internal data class HouseOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class HouseTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) +``` + +Mapping `HouseTierOffer` → `TierOfferData` (purchase-common's nav-passable model) happens at the nav-graph boundary, exactly as in `CarPurchaseNavGraph`. + +## Navigation + +```kotlin +@Serializable +data class HousePurchaseGraphDestination(val productName: String) : Destination + +internal sealed interface HousePurchaseDestination : Destination { + @Serializable + data object Form : HousePurchaseDestination +} +``` + +`HousePurchaseNavGraph` wires: + +``` +HousePurchaseGraph(productName) + startDestination = Form + Form → SelectTier(params) [purchase-common] + SelectTier → Summary(params) [purchase-common] + Summary → Signing(params) [purchase-common] + Signing → Success(startDate) [purchase-common] + (typedPopUpTo(inclusive = true)) +``` + +`SelectTier`, `Summary`, `Signing`, `Success`, `Failure` are imported from `purchase-common.navigation.PurchaseCommonDestination` — identical pattern to `CarPurchaseNavGraph`. + +## DI module + +```kotlin +val housePurchaseModule = module { + single { + CreateHouseSessionAndPriceIntentUseCaseImpl(apolloClient = get()) + } + single { + SubmitVacationHomeFormAndGetOffersUseCaseImpl(apolloClient = get()) + } + viewModel { params -> + VacationHomeFormViewModel( + productName = params.get(), + createHouseSessionAndPriceIntentUseCase = get(), + submitVacationHomeFormAndGetOffersUseCase = get(), + ) + } +} +``` + +`CreateHouseSessionAndPriceIntentUseCase` is product-agnostic (takes `productName: String`) so the future SE_HOUSE form composable can reuse it. `SubmitVacationHomeFormAndGetOffersUseCase` is vacation-home-specific because the form-data keys differ from SE_HOUSE; the future `SubmitHouseFormAndGetOffersUseCase` will be a sibling. + +## Testing and verification + +Following the apartment/car precedent (no JVM unit tests for the form layer). Verification gates: + +- `./gradlew :feature-purchase-house:assemble` — module builds +- `./gradlew :app:assembleDevelopDebug` — full app builds +- `./gradlew ktlintFormat && ./gradlew ktlintCheck` +- Manual emulator verification per the `verifying-android-changes-in-emulator` skill — golden path: cross-sell → form → tier select → summary → BankID → success. Edge cases: form validation, navigate-up at each step, error states (network failure, userError on PriceIntentDataUpdate, missing offers). + +**Compose previews required** (matches apartment): empty state, filled state, loading session state, error state. + +## Lokalise / translations + +Per CLAUDE.md, string resource XML files are managed by Lokalise and must not be edited directly. All new UI text is hardcoded in Swedish in the Kotlin source with a `// TODO: Add "" / "" to Lokalise` comment, mirroring how the car form shipped. + +New strings expected: +- Form title and subtitle +- 8 field labels and validation messages +- Radio Ja / Nej labels (reuse if existing strings cover them) +- Submit button text (likely reuse "Beräkna pris" from car/apartment) +- Bathrooms stepper value label + +## Edge cases and error handling + +- **Missing member SSN** → `CreateHouseSessionAndPriceIntentUseCase` raises `ErrorMessage()` (same as car). +- **Empty offers from confirm** → raises `ErrorMessage()`. +- **`userError` on `priceIntentDataUpdate` or `priceIntentConfirm`** → message surfaced in `ErrorDialog`. +- **Apollo `safeExecute` left-side failures** → generic error logged via `logcat(LogPriority.ERROR)` + `ErrorMessage()`. + +## Key design decisions + +1. **Single feature module for SE_VACATION_HOME and (future) SE_HOUSE.** Same Apollo mutations + 60% field overlap + shared form components make a single module cheaper than duplicating the apartment/car-style scaffolding. Two separate form composables keep product-specific UI focused. + +2. **`House`-prefixed Apollo operations.** Required for Apollo Kotlin codegen isolation from apartment/car modules (operation names must be unique across the classpath). SDL is identical. + +3. **Vacation home first, house second.** Smaller PR, mirrors how apartment shipped (one product per PR). The follow-up SE_HOUSE PR is then small: a new `HouseFormDestination` composable + a productName branch in the nav graph + a new `SubmitHouseFormAndGetOffersUseCase`. + +4. **`extraBuildings` UI deferred to a separate PR.** V1 always sends `extraBuildings: []`. Pricing won't reflect extra buildings, but users can complete the flow. The follow-up PR adds the add/remove dialog and card list (~14 building types). + +5. **`CreateHouseSessionAndPriceIntentUseCase` accepts `productName: String`.** Shared between SE_VACATION_HOME and (future) SE_HOUSE without changes. + +6. **Cross-sell URL routing ordering.** Fritidshus check placed before apartment branches in `InsuranceGraph.kt` to defend against future SE_HOUSE URL conflicts with apartment's generic `hemforsakring`/`home-insurance` match. + +## Out of scope + +- `extraBuildings` dialog/list UI (deferred to a separate follow-up PR; v1 sends empty array) +- SE_HOUSE form composable (separate follow-up PR within the same module) +- Fixing the latent bug where apartment's `hemforsakring`/`home-insurance` keyword match would steal future SE_HOUSE URLs (`hemforsakring/villaforsakring`, `home-insurance/house`) — flag for SE_HOUSE PR +- Bundle discount UI surfacing for vacation-home offers (purchase-common already handles bundle discount fields generically) +- Tracking / Datadog events specific to vacation-home purchase From 87b8b6587389b17b5d09daaeb43198b1ef61e0ed Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 07:44:42 +0200 Subject: [PATCH 02/15] chore: add implementation plan for in-app vacation home (fritidshus) purchase 13 tasks: scaffold feature-purchase-house module, Apollo ops, use cases, ViewModel, form composable with 8 fields (3 radios, 1 stepper, 4 text/number), nav graph, DI, cross-sell routing, app wiring, emulator verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-21-in-app-vacation-home-purchase.md | 1705 +++++++++++++++++ 1 file changed, 1705 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-in-app-vacation-home-purchase.md diff --git a/docs/superpowers/plans/2026-05-21-in-app-vacation-home-purchase.md b/docs/superpowers/plans/2026-05-21-in-app-vacation-home-purchase.md new file mode 100644 index 0000000000..9c7bd6f6d0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-in-app-vacation-home-purchase.md @@ -0,0 +1,1705 @@ +# In-app Vacation Home (Fritidshus) Purchase Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create `feature-purchase-house` module hosting the `SE_VACATION_HOME` (fritidshus) in-app purchase flow, wire it into app navigation, and route fritidshus cross-sell URLs to it. + +**Architecture:** New feature module `feature-purchase-house` depends on the existing `purchase-common` module (which lives on `feat/in-app-car-purchase`). The module owns a `VacationHomeFormDestination` composable, its ViewModel/Presenter, two use cases (session + form-submit), one set of `House`-prefixed Apollo operations, a nav graph, and a DI module. After the form, navigation hands off to `purchase-common` for tier selection, summary, BankID signing, and success/failure — identical to how car and apartment work. + +**Tech Stack:** Kotlin, Jetpack Compose, Apollo GraphQL, Molecule (MVI), Koin DI, Arrow (Either), kotlinx.serialization. + +**Base branch:** This work depends on the `purchase-common` module, which currently only lives on `feat/in-app-car-purchase`. The implementation branch **must** be cut from `feat/in-app-car-purchase` (not `develop`). + +--- + +### Task 0: Verify base branch + +**Files:** none + +- [ ] **Step 1: Confirm the current branch is based off `feat/in-app-car-purchase`** + +Run: `git log --oneline feat/in-app-car-purchase..HEAD | head -5; git merge-base --is-ancestor feat/in-app-car-purchase HEAD && echo "OK: branched from car" || echo "FAIL: not branched from car"` +Expected: `OK: branched from car` (the merge-base check confirms car is an ancestor of HEAD). + +- [ ] **Step 2: Confirm `purchase-common` exists** + +Run: `ls app/purchase-common/build.gradle.kts && ls app/purchase-common/src/main/kotlin/com/hedvig/android/feature/purchase/common/ui/offer/SelectTierDestination.kt` +Expected: Both files exist (no `No such file or directory` errors). + +If either check fails, stop — the branch is not based off `feat/in-app-car-purchase`. Rebase or branch correctly before continuing. + +--- + +### Task 1: Create `feature-purchase-house` module scaffold + +**Files:** +- Create: `app/feature/feature-purchase-house/build.gradle.kts` + +- [ ] **Step 1: Create `build.gradle.kts`** + +File: `app/feature/feature-purchase-house/build.gradle.kts` + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.purchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} +``` + +- [ ] **Step 2: Verify Gradle picks up the new module** + +Run: `./gradlew :feature-purchase-house:tasks --quiet | head -5` +Expected: Gradle prints tasks (no `Project ... not found` error). The settings.gradle.kts auto-discovers everything under `app/`. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/build.gradle.kts +git commit -m "feat: scaffold feature-purchase-house module" +``` + +--- + +### Task 2: Add GraphQL operations + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/graphql/HouseShopSessionCreateMutation.graphql` +- Create: `app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentCreateMutation.graphql` +- Create: `app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentDataUpdateMutation.graphql` +- Create: `app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentConfirmMutation.graphql` +- Create: `app/feature/feature-purchase-house/src/main/graphql/HouseMemberContactInfoQuery.graphql` +- Create: `app/feature/feature-purchase-house/src/main/graphql/HouseProductOfferFragment.graphql` + +- [ ] **Step 1: Create `HouseShopSessionCreateMutation.graphql`** + +```graphql +mutation HouseShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} +``` + +- [ ] **Step 2: Create `HousePriceIntentCreateMutation.graphql`** + +```graphql +mutation HousePriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} +``` + +- [ ] **Step 3: Create `HousePriceIntentDataUpdateMutation.graphql`** + +```graphql +mutation HousePriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} +``` + +- [ ] **Step 4: Create `HousePriceIntentConfirmMutation.graphql`** + +```graphql +mutation HousePriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...HouseProductOfferFragment + } + } + userError { + message + } + } +} +``` + +- [ ] **Step 5: Create `HouseMemberContactInfoQuery.graphql`** + +```graphql +query HouseMemberContactInfo { + currentMember { + id + ssn + email + } +} +``` + +- [ ] **Step 6: Create `HouseProductOfferFragment.graphql`** + +```graphql +fragment HouseProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} +``` + +- [ ] **Step 7: Run Apollo codegen and assemble** + +Run: `./gradlew :feature-purchase-house:generateApolloSources :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. Generated Kotlin classes appear under `app/feature/feature-purchase-house/build/generated/source/apollo/octopus/octopus/`. + +- [ ] **Step 8: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/graphql/ +git commit -m "feat: add GraphQL operations for house purchase module" +``` + +--- + +### Task 3: Add domain models + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt` + +- [ ] **Step 1: Create `HousePurchaseModels.kt`** + +```kotlin +package com.hedvig.android.feature.purchase.house.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, + val ssn: String, + val email: String, +) + +internal data class HouseOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class HouseTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `./gradlew :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt +git commit -m "feat: add domain models for house purchase module" +``` + +--- + +### Task 4: Add `CreateHouseSessionAndPriceIntentUseCase` + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt` + +- [ ] **Step 1: Create the use case** + +```kotlin +package com.hedvig.android.feature.purchase.house.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.HouseMemberContactInfoQuery +import octopus.HousePriceIntentCreateMutation +import octopus.HouseShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateHouseSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateHouseSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateHouseSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(HouseShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(HousePriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + val member = apolloClient + .query(HouseMemberContactInfoQuery()) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to fetch member contact info: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.currentMember }, + ) + val ssn = member.ssn + if (ssn == null) { + logcat(LogPriority.ERROR) { "Member is missing SSN — cannot continue house purchase" } + raise(ErrorMessage()) + } + + SessionAndIntent( + shopSessionId = shopSessionId, + priceIntentId = priceIntentId, + ssn = ssn, + email = member.email, + ) + } + } +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `./gradlew :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt +git commit -m "feat: add CreateHouseSessionAndPriceIntentUseCase" +``` + +--- + +### Task 5: Add `SubmitVacationHomeFormAndGetOffersUseCase` + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt` + +- [ ] **Step 1: Create the use case** + +```kotlin +package com.hedvig.android.feature.purchase.house.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.HousePriceIntentConfirmMutation +import octopus.HousePriceIntentDataUpdateMutation +import octopus.fragment.HouseProductOfferFragment + +internal interface SubmitVacationHomeFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + email: String, + street: String, + zipCode: String, + multipleOwners: Boolean, + yearOfConstruction: Int, + livingSpace: Int, + hasWaterConnected: Boolean, + numberOfBathrooms: Int, + isSubleted: Boolean, + ): Either +} + +internal class SubmitVacationHomeFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitVacationHomeFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + email: String, + street: String, + zipCode: String, + multipleOwners: Boolean, + yearOfConstruction: Int, + livingSpace: Int, + hasWaterConnected: Boolean, + numberOfBathrooms: Int, + isSubleted: Boolean, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("email", email) + put("street", street) + put("zipCode", zipCode) + put("multipleOwners", multipleOwners) + put("yearOfConstruction", yearOfConstruction) + put("livingSpace", livingSpace) + put("hasWaterConnected", hasWaterConnected) + put("numberOfBathrooms", numberOfBathrooms) + put("isSubleted", isSubleted) + put("extraBuildings", emptyList>()) + } + + val updateResult = apolloClient + .mutation(HousePriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(HousePriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + HouseOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toHouseTierOffer() }, + ) + } + } +} + +internal fun HouseProductOfferFragment.toHouseTierOffer(): HouseTierOffer { + return HouseTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `./gradlew :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt +git commit -m "feat: add SubmitVacationHomeFormAndGetOffersUseCase" +``` + +--- + +### Task 6: Add `VacationHomeFormViewModel` + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt` + +- [ ] **Step 1: Create the ViewModel + Presenter** + +```kotlin +package com.hedvig.android.feature.purchase.house.ui.vacationhome + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.house.data.HouseOffers +import com.hedvig.android.feature.purchase.house.data.SessionAndIntent +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import java.time.LocalDate + +internal class VacationHomeFormViewModel( + productName: String, + createHouseSessionAndPriceIntentUseCase: CreateHouseSessionAndPriceIntentUseCase, + submitVacationHomeFormAndGetOffersUseCase: SubmitVacationHomeFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = VacationHomeFormState(), + presenter = VacationHomeFormPresenter( + productName, + createHouseSessionAndPriceIntentUseCase, + submitVacationHomeFormAndGetOffersUseCase, + ), + ) + +internal sealed interface VacationHomeFormEvent { + data class SubmitForm( + val street: String, + val zipCode: String, + val multipleOwners: Boolean?, + val yearOfConstruction: String, + val livingSpace: String, + val hasWaterConnected: Boolean?, + val numberOfBathrooms: Int, + val isSubleted: Boolean?, + ) : VacationHomeFormEvent + + data object ClearNavigation : VacationHomeFormEvent + + data object Retry : VacationHomeFormEvent + + data object DismissError : VacationHomeFormEvent +} + +internal data class VacationHomeFormState( + val streetError: String? = null, + val zipCodeError: String? = null, + val multipleOwnersError: String? = null, + val yearOfConstructionError: String? = null, + val livingSpaceError: String? = null, + val hasWaterConnectedError: String? = null, + val isSubletedError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: OffersNavigationData? = null, +) + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: HouseOffers, +) + +private class VacationHomeFormPresenter( + private val productName: String, + private val createHouseSessionAndPriceIntentUseCase: CreateHouseSessionAndPriceIntentUseCase, + private val submitVacationHomeFormAndGetOffersUseCase: SubmitVacationHomeFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: VacationHomeFormState, + ): VacationHomeFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: VacationHomeFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is VacationHomeFormEvent.SubmitForm -> { + val errors = validate( + street = event.street, + zipCode = event.zipCode, + multipleOwners = event.multipleOwners, + yearOfConstruction = event.yearOfConstruction, + livingSpace = event.livingSpace, + hasWaterConnected = event.hasWaterConnected, + isSubleted = event.isSubleted, + ) + if (errors.hasErrors()) { + currentState = currentState.copy( + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + multipleOwnersError = errors.multipleOwnersError, + yearOfConstructionError = errors.yearOfConstructionError, + livingSpaceError = errors.livingSpaceError, + hasWaterConnectedError = errors.hasWaterConnectedError, + isSubletedError = errors.isSubletedError, + ) + } else { + currentState = currentState.copy( + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + VacationHomeFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + VacationHomeFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + + VacationHomeFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createHouseSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + val multipleOwners = submit.multipleOwners ?: return@LaunchedEffect + val yearOfConstruction = submit.yearOfConstruction.toIntOrNull() ?: return@LaunchedEffect + val livingSpace = submit.livingSpace.toIntOrNull() ?: return@LaunchedEffect + val hasWaterConnected = submit.hasWaterConnected ?: return@LaunchedEffect + val isSubleted = submit.isSubleted ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitVacationHomeFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = session.ssn, + email = session.email, + street = submit.street, + zipCode = submit.zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = submit.numberOfBathrooms, + isSubleted = isSubleted, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "Something went wrong", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val streetError: String?, + val zipCodeError: String?, + val multipleOwnersError: String?, + val yearOfConstructionError: String?, + val livingSpaceError: String?, + val hasWaterConnectedError: String?, + val isSubletedError: String?, +) { + fun hasErrors(): Boolean = streetError != null || + zipCodeError != null || + multipleOwnersError != null || + yearOfConstructionError != null || + livingSpaceError != null || + hasWaterConnectedError != null || + isSubletedError != null +} + +private fun validate( + street: String, + zipCode: String, + multipleOwners: Boolean?, + yearOfConstruction: String, + livingSpace: String, + hasWaterConnected: Boolean?, + isSubleted: Boolean?, +): ValidationErrors { + val currentYear = LocalDate.now().year + return ValidationErrors( + streetError = if (street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + else -> null + }, + multipleOwnersError = if (multipleOwners == null) "Välj ett alternativ" else null, + yearOfConstructionError = when (val year = yearOfConstruction.toIntOrNull()) { + null -> "Ange byggår" + !in 1700..currentYear -> "Ange ett giltigt byggår" + else -> null + }, + livingSpaceError = when (val space = livingSpace.toIntOrNull()) { + null -> "Ange boyta" + !in 1..Int.MAX_VALUE -> "Ange en giltig boyta" + else -> null + }, + hasWaterConnectedError = if (hasWaterConnected == null) "Välj ett alternativ" else null, + isSubletedError = if (isSubleted == null) "Välj ett alternativ" else null, + ) +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `./gradlew :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt +git commit -m "feat: add VacationHomeFormViewModel" +``` + +--- + +### Task 7: Add `VacationHomeFormDestination` + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt` + +- [ ] **Step 1: Create the composable** + +```kotlin +package com.hedvig.android.feature.purchase.house.ui.vacationhome + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ErrorDialog +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigStepper +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioGroupStyle +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperSize.Medium +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperStyle.Labeled +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.house.data.HouseOffers + +@Composable +internal fun VacationHomeFormDestination( + viewModel: VacationHomeFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: HouseOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(VacationHomeFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(VacationHomeFormEvent.Retry) }, + ) + } + + else -> { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var multipleOwners by remember { mutableStateOf(null) } + var yearOfConstruction by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var hasWaterConnected by remember { mutableStateOf(null) } + var numberOfBathrooms by remember { mutableIntStateOf(1) } + var isSubleted by remember { mutableStateOf(null) } + + if (uiState.submitError != null) { + ErrorDialog( + title = "Något gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(VacationHomeFormEvent.DismissError) }, + ) + } + VacationHomeFormContent( + street = street, + zipCode = zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = numberOfBathrooms, + isSubleted = isSubleted, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + multipleOwnersError = uiState.multipleOwnersError, + yearOfConstructionError = uiState.yearOfConstructionError, + livingSpaceError = uiState.livingSpaceError, + hasWaterConnectedError = uiState.hasWaterConnectedError, + isSubletedError = uiState.isSubletedError, + isSubmitting = uiState.isSubmitting, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() } && value.length <= 5) zipCode = value }, + onMultipleOwnersChanged = { multipleOwners = it }, + onYearOfConstructionChanged = { value -> + if (value.isEmpty() || (value.all { it.isDigit() } && value.length <= 4)) yearOfConstruction = value + }, + onLivingSpaceChanged = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value + }, + onHasWaterConnectedChanged = { hasWaterConnected = it }, + onNumberOfBathroomsChanged = { numberOfBathrooms = it }, + onIsSubletedChanged = { isSubleted = it }, + onSubmit = { + viewModel.emit( + VacationHomeFormEvent.SubmitForm( + street = street, + zipCode = zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = numberOfBathrooms, + isSubleted = isSubleted, + ), + ) + }, + ) + } + } + } +} + +@Composable +private fun VacationHomeFormContent( + street: String, + zipCode: String, + multipleOwners: Boolean?, + yearOfConstruction: String, + livingSpace: String, + hasWaterConnected: Boolean?, + numberOfBathrooms: Int, + isSubleted: Boolean?, + streetError: String?, + zipCodeError: String?, + multipleOwnersError: String?, + yearOfConstructionError: String?, + livingSpaceError: String?, + hasWaterConnectedError: String?, + isSubletedError: String?, + isSubmitting: Boolean, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onMultipleOwnersChanged: (Boolean) -> Unit, + onYearOfConstructionChanged: (String) -> Unit, + onLivingSpaceChanged: (String) -> Unit, + onHasWaterConnectedChanged: (Boolean) -> Unit, + onNumberOfBathroomsChanged: (Int) -> Unit, + onIsSubletedChanged: (Boolean) -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + // TODO: Add "Fill in your details and we'll calculate your price" / "Fyll i dina uppgifter så beräknar vi ditt pris" to Lokalise + HedvigText( + text = "Fyll i dina uppgifter så beräknar vi ditt pris", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // TODO: Add "Address" / "Adress" to Lokalise + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + // TODO: Add "Postal code" / "Postnummer" to Lokalise + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Do you own the house with someone else?" / "Äger du huset tillsammans med någon annan?" to Lokalise + HedvigText( + text = "Äger du huset tillsammans med någon annan?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = multipleOwners, + onSelectionChanged = onMultipleOwnersChanged, + enabled = !isSubmitting, + errorText = multipleOwnersError, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Year built" / "Byggår" to Lokalise + HedvigTextField( + text = yearOfConstruction, + onValueChange = onYearOfConstructionChanged, + labelText = "Byggår", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = yearOfConstructionError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + // TODO: Add "Living space (m²)" / "Boyta (kvm)" to Lokalise + HedvigTextField( + text = livingSpace, + onValueChange = onLivingSpaceChanged, + labelText = "Boyta (kvm)", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = livingSpaceError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Is water connected?" / "Är vatten anslutet?" to Lokalise + HedvigText( + text = "Är vatten anslutet?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = hasWaterConnected, + onSelectionChanged = onHasWaterConnectedChanged, + enabled = !isSubmitting, + errorText = hasWaterConnectedError, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Number of bathrooms" / "Antal badrum" to Lokalise + HedvigStepper( + text = when (numberOfBathrooms) { + 1 -> "1 badrum" + else -> "$numberOfBathrooms badrum" + }, + stepperSize = Medium, + stepperStyle = Labeled("Antal badrum"), + onMinusClick = { onNumberOfBathroomsChanged(numberOfBathrooms - 1) }, + onPlusClick = { onNumberOfBathroomsChanged(numberOfBathrooms + 1) }, + isPlusEnabled = !isSubmitting && numberOfBathrooms < 10, + isMinusEnabled = !isSubmitting && numberOfBathrooms > 1, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Do you sublet all or parts of the house?" / "Hyr du ut hela eller delar av huset?" to Lokalise + HedvigText( + text = "Hyr du ut hela eller delar av huset?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = isSubleted, + onSelectionChanged = onIsSubletedChanged, + enabled = !isSubmitting, + errorText = isSubletedError, + ) + } + Spacer(Modifier.height(16.dp)) + // TODO: Add "Calculate price" / "Beräkna pris" to Lokalise + HedvigButton( + text = "Beräkna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private const val OPTION_YES = "YES" +private const val OPTION_NO = "NO" + +@Composable +private fun YesNoRadio( + selected: Boolean?, + onSelectionChanged: (Boolean) -> Unit, + enabled: Boolean, + errorText: String?, +) { + val options = listOf( + // TODO: Add "Yes" / "Ja" to Lokalise + RadioOption(id = RadioOptionId(OPTION_YES), text = "Ja"), + // TODO: Add "No" / "Nej" to Lokalise + RadioOption(id = RadioOptionId(OPTION_NO), text = "Nej"), + ) + val selectedId = when (selected) { + true -> RadioOptionId(OPTION_YES) + false -> RadioOptionId(OPTION_NO) + null -> null + } + RadioGroup( + options = options, + selectedOption = selectedId, + onRadioOptionSelected = { id -> + onSelectionChanged(id == RadioOptionId(OPTION_YES)) + }, + style = RadioGroupStyle.Horizontal, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + if (errorText != null) { + HedvigText( + text = errorText, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.signalRedElement, + ) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "", + zipCode = "", + multipleOwners = null, + yearOfConstruction = "", + livingSpace = "", + hasWaterConnected = null, + numberOfBathrooms = 1, + isSubleted = null, + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormFilled() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "Storgatan 1", + zipCode = "12345", + multipleOwners = false, + yearOfConstruction = "1985", + livingSpace = "60", + hasWaterConnected = true, + numberOfBathrooms = 1, + isSubleted = false, + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormErrors() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "", + zipCode = "12", + multipleOwners = null, + yearOfConstruction = "1500", + livingSpace = "", + hasWaterConnected = null, + numberOfBathrooms = 1, + isSubleted = null, + streetError = "Ange en adress", + zipCodeError = "Ange ett giltigt postnummer (5 siffror)", + multipleOwnersError = "Välj ett alternativ", + yearOfConstructionError = "Ange ett giltigt byggår", + livingSpaceError = "Ange boyta", + hasWaterConnectedError = "Välj ett alternativ", + isSubletedError = "Välj ett alternativ", + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} +``` + +- [ ] **Step 2: Build and check ktlint** + +Run: `./gradlew :feature-purchase-house:assemble :feature-purchase-house:ktlintFormat :feature-purchase-house:ktlintCheck` +Expected: BUILD SUCCESSFUL on all three; any auto-formatting from `ktlintFormat` is already applied before `ktlintCheck` runs. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt +git commit -m "feat: add VacationHomeFormDestination with Compose previews" +``` + +--- + +### Task 8: Add navigation destinations + nav graph + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseDestination.kt` +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseNavGraph.kt` + +- [ ] **Step 1: Create `HousePurchaseDestination.kt`** + +```kotlin +package com.hedvig.android.feature.purchase.house.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class HousePurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface HousePurchaseDestination { + @Serializable + data object Form : HousePurchaseDestination, Destination +} +``` + +- [ ] **Step 2: Create `HousePurchaseNavGraph.kt`** + +```kotlin +package com.hedvig.android.feature.purchase.house.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.feature.purchase.house.navigation.HousePurchaseDestination.Form +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormDestination +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.housePurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination
{ backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: VacationHomeFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + VacationHomeFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + ) + } + + navdestination(Signing) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SigningViewModel = koinViewModel { + parametersOf(route.params) + } + SigningDestination( + viewModel = viewModel, + navigateToSuccess = { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + ) + } + } +} +``` + +- [ ] **Step 3: Build and check ktlint** + +Run: `./gradlew :feature-purchase-house:assemble :feature-purchase-house:ktlintFormat :feature-purchase-house:ktlintCheck` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/ +git commit -m "feat: add navigation graph for house purchase module" +``` + +--- + +### Task 9: Add DI module + +**Files:** +- Create: `app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt` + +- [ ] **Step 1: Create the Koin module** + +```kotlin +package com.hedvig.android.feature.purchase.house.di + +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val housePurchaseModule = module { + single { CreateHouseSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitVacationHomeFormAndGetOffersUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + VacationHomeFormViewModel( + productName = params.get(), + createHouseSessionAndPriceIntentUseCase = get(), + submitVacationHomeFormAndGetOffersUseCase = get(), + ) + } +} +``` + +- [ ] **Step 2: Build** + +Run: `./gradlew :feature-purchase-house:assemble` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt +git commit -m "feat: add Koin module for house purchase" +``` + +--- + +### Task 10: Wire `feature-insurances` cross-sell routing + +**Files:** +- Modify: `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt` + +- [ ] **Step 1: Add `onNavigateToHousePurchase` parameter to the graph function** + +In `InsuranceGraph.kt`, find the existing parameter list that includes `onNavigateToCarPurchase: (productName: String) -> Unit` and add: + +```kotlin +onNavigateToHousePurchase: (productName: String) -> Unit, +``` + +Place it directly after `onNavigateToCarPurchase` in the function signature for consistency with how callbacks are ordered. + +- [ ] **Step 2: Route fritidshus URLs in `onCrossSellClick`** + +In the same file, find the `when { ... }` block inside `onCrossSellClick = dropUnlessResumed { url: String -> ... }`. Replace it with: + +```kotlin +when { + "fritidshusforsakring" in lower || "vacation-home" in lower -> + onNavigateToHousePurchase("SE_VACATION_HOME") + "car-insurance" in lower || "bilforsakring" in lower -> + onNavigateToCarPurchase("SE_CAR") + "bostadsratt" in lower || "home-insurance/homeowner" in lower -> + onNavigateToApartmentPurchase("SE_APARTMENT_BRF") + "hyresratt" in lower || "home-insurance" in lower || "hemforsakring" in lower -> + onNavigateToApartmentPurchase("SE_APARTMENT_RENT") + else -> openUrl(url) +} +``` + +The fritidshus branch is first; even though there's no current substring conflict, this ordering defends against future SE_HOUSE additions where `hemforsakring/villaforsakring` would otherwise be stolen by the apartment branch. + +- [ ] **Step 3: Build and verify** + +Run: `./gradlew :feature-insurances:assemble :feature-insurances:ktlintFormat :feature-insurances:ktlintCheck` +Expected: BUILD SUCCESSFUL. (At this point the `app` module won't compile yet because it doesn't pass `onNavigateToHousePurchase`; that's wired in Task 11.) + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt +git commit -m "feat: route fritidshus cross-sells to in-app purchase flow" +``` + +--- + +### Task 11: Wire `housePurchaseModule` and `housePurchaseNavGraph` into the app + +**Files:** +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt` +- Modify: `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt` + +- [ ] **Step 1: Register `housePurchaseModule` in `ApplicationModule.kt`** + +Add this import (place near the other purchase-module imports, alphabetically with `carPurchaseModule`): + +```kotlin +import com.hedvig.android.feature.purchase.house.di.housePurchaseModule +``` + +Then add `housePurchaseModule,` to the `includes(listOf(...))` block — insert it directly after `carPurchaseModule,`: + +```kotlin +val applicationModule = module { + includes( + listOf( + addonPurchaseModule, + addonRemovalModule, + apartmentPurchaseModule, + carPurchaseModule, + housePurchaseModule, + // ... existing modules continue ... +``` + +- [ ] **Step 2: Add nav graph import in `HedvigNavHost.kt`** + +Add this import (place near other `feature.purchase.car.navigation` imports): + +```kotlin +import com.hedvig.android.feature.purchase.house.navigation.HousePurchaseGraphDestination +import com.hedvig.android.feature.purchase.house.navigation.housePurchaseNavGraph +``` + +- [ ] **Step 3: Wire the insurances graph callback** + +In `HedvigNavHost.kt`, find the existing insurances graph callsite (look for `onNavigateToCarPurchase = { productName -> navController.navigate(CarPurchaseGraphDestination(productName)) },`). Add directly after it: + +```kotlin + onNavigateToHousePurchase = { productName -> + navController.navigate(HousePurchaseGraphDestination(productName)) + }, +``` + +- [ ] **Step 4: Register the nav graph in `HedvigNavHost`** + +Find the existing `carPurchaseNavGraph(...)` call and add `housePurchaseNavGraph(...)` directly after it, with the same arguments: + +```kotlin + carPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) + housePurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) +``` + +- [ ] **Step 5: Build the full app** + +Run: `./gradlew :app:assembleDevelopDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Run ktlint across modified modules** + +Run: `./gradlew :app:ktlintFormat :app:ktlintCheck :feature-purchase-house:ktlintCheck :feature-insurances:ktlintCheck` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt \ + app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt +git commit -m "feat: integrate house purchase flow into main app navigation" +``` + +--- + +### Task 12: Manual emulator verification + +**Files:** none (verification only) + +This task follows the `verifying-android-changes-in-emulator` skill. Manual verification is required; type-checking and unit tests are not sufficient evidence for UI/navigation work. + +- [ ] **Step 1: Install on emulator** + +Run: `./gradlew :app:installDevelopDebug` +Expected: app installed on the running emulator. If no emulator is running, start one first. + +- [ ] **Step 2: Verify golden path** + +1. Open the app, navigate to **Insurances** tab. +2. Trigger a fritidshus cross-sell. Easiest path: use a deep-link test or inject a cross-sell URL containing `fritidshusforsakring` via debug menu, or follow the natural cross-sell surface for this user. +3. Verify the form opens. +4. Fill in all fields with valid values (any plausible street, zip `12345`, year `1985`, livingSpace `60`, bathrooms `1`, all three radios `Ja`/`Nej`). +5. Tap "Beräkna pris". +6. Verify `SelectTier` screen appears with BAS and STANDARD offers. +7. Select STANDARD, continue to Summary. +8. Continue to Signing — BankID flow should start. +9. Complete signing on the test BankID app (or use the QR fallback if testing on a different device). +10. Verify `PurchaseSuccess` screen appears with a start date. +11. Tap close — verify navigation returns to the Insurances tab (not closing the app). + +- [ ] **Step 3: Verify form validation edge cases** + +For each of these, observe that the right error appears under the field and submission is blocked: +1. Submit with all fields empty → 7 validation errors (radio errors below each radio group, text errors inside each text field). +2. Enter zip code `123` (3 digits) → "Ange ett giltigt postnummer (5 siffror)". +3. Enter yearOfConstruction `1500` → "Ange ett giltigt byggår". +4. Enter livingSpace `0` → "Ange en giltig boyta". + +- [ ] **Step 4: Verify error states** + +1. Turn off device wifi/data, open the flow → `HedvigErrorSection` appears after the session-create call fails. Tap retry; turn data back on; verify the form loads. +2. Fill the form with valid values but a backend-rejected payload (e.g. a yearOfConstruction that the backend rejects, if known). Verify the `ErrorDialog` appears with the userError message. Dismiss and verify the form is interactive again. + +- [ ] **Step 5: Verify navigate-up at each step** + +From each of: Form, SelectTier, Summary, Signing — press the back arrow / system back. Confirm the user lands one step back, and that pressing back from Form returns to the Insurances tab (not closing the app). + +- [ ] **Step 6: Record verification notes** + +Record what worked and any deviations in the eventual PR description. If anything failed, fix it and re-run from Step 1. + +--- + +### Task 13: PR-prep, ktlint, full build + +**Files:** none + +- [ ] **Step 1: Full app build (release flavor for sanity)** + +Run: `./gradlew :app:assembleStagingDebug` +Expected: BUILD SUCCESSFUL. (Staging flavor catches any release-build-only issues.) + +- [ ] **Step 2: Top-level ktlint check** + +Run: `./gradlew ktlintCheck` +Expected: BUILD SUCCESSFUL across all modules. + +- [ ] **Step 3: Lint the new module** + +Run: `./gradlew :feature-purchase-house:lint` +Expected: no new lint errors. Address any new findings before opening PR. + +- [ ] **Step 4: Verify nothing unintended is staged** + +Run: `git status` and `git log feat/in-app-car-purchase..HEAD --oneline` +Expected: clean working tree; commit list reads as a coherent feature progression. + +- [ ] **Step 5: Open PR** + +Push the branch and open a PR against `feat/in-app-car-purchase` (or `develop` if car has merged by now). Title format (no Notion ID, since this is `feat`-scoped but the user can attach one if applicable): `Add in-app vacation home (fritidshus) purchase flow`. Body should summarize: +- New `feature-purchase-house` module hosting `VacationHomeFormDestination` +- Cross-sell URL routing for `fritidshusforsakring` / `vacation-home` +- Extra-buildings UI deferred to follow-up PR (empty list sent in V1) +- SE_HOUSE form deferred to follow-up PR (module is forward-named to accommodate it) +- Pre-existing apartment-substring routing risk for future SE_HOUSE URLs (flagged for that PR, not fixed here) From fe4ee32d618596f563e5fdb2bbbde8690a152d08 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 07:53:09 +0200 Subject: [PATCH 03/15] feat: scaffold feature-purchase-house module --- .../feature-purchase-house/build.gradle.kts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/feature/feature-purchase-house/build.gradle.kts diff --git a/app/feature/feature-purchase-house/build.gradle.kts b/app/feature/feature-purchase-house/build.gradle.kts new file mode 100644 index 0000000000..b7d142a990 --- /dev/null +++ b/app/feature/feature-purchase-house/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() +} + +android { + testOptions.unitTests.isReturnDefaultValues = true +} + +dependencies { + api(libs.androidx.navigation.common) + + implementation(libs.androidx.navigation.compose) + implementation(libs.arrow.core) + implementation(libs.arrow.fx) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.koin.composeViewModel) + implementation(libs.koin.core) + implementation(libs.kotlinx.serialization.core) + implementation(projects.apolloCore) + implementation(projects.apolloOctopusPublic) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.coreUiData) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.designSystemHedvig) + implementation(projects.purchaseCommon) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) +} From 557d6aa548fb1b4d00b990a74307e64bcf54efb5 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:05:10 +0200 Subject: [PATCH 04/15] feat: add GraphQL operations for house purchase module --- .../HouseMemberContactInfoQuery.graphql | 7 +++ .../HousePriceIntentConfirmMutation.graphql | 13 +++++ .../HousePriceIntentCreateMutation.graphql | 5 ++ ...HousePriceIntentDataUpdateMutation.graphql | 10 ++++ .../graphql/HouseProductOfferFragment.graphql | 50 +++++++++++++++++++ .../HouseShopSessionCreateMutation.graphql | 5 ++ 6 files changed, 90 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HouseMemberContactInfoQuery.graphql create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentConfirmMutation.graphql create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentCreateMutation.graphql create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentDataUpdateMutation.graphql create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HouseProductOfferFragment.graphql create mode 100644 app/feature/feature-purchase-house/src/main/graphql/HouseShopSessionCreateMutation.graphql diff --git a/app/feature/feature-purchase-house/src/main/graphql/HouseMemberContactInfoQuery.graphql b/app/feature/feature-purchase-house/src/main/graphql/HouseMemberContactInfoQuery.graphql new file mode 100644 index 0000000000..f35a9517ed --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HouseMemberContactInfoQuery.graphql @@ -0,0 +1,7 @@ +query HouseMemberContactInfo { + currentMember { + id + ssn + email + } +} diff --git a/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentConfirmMutation.graphql new file mode 100644 index 0000000000..48c80cae7a --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentConfirmMutation.graphql @@ -0,0 +1,13 @@ +mutation HousePriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...HouseProductOfferFragment + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentCreateMutation.graphql b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentCreateMutation.graphql new file mode 100644 index 0000000000..950e430486 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation HousePriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} diff --git a/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentDataUpdateMutation.graphql b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentDataUpdateMutation.graphql new file mode 100644 index 0000000000..a568335347 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HousePriceIntentDataUpdateMutation.graphql @@ -0,0 +1,10 @@ +mutation HousePriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-house/src/main/graphql/HouseProductOfferFragment.graphql b/app/feature/feature-purchase-house/src/main/graphql/HouseProductOfferFragment.graphql new file mode 100644 index 0000000000..04cc8023ae --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HouseProductOfferFragment.graphql @@ -0,0 +1,50 @@ +fragment HouseProductOfferFragment on ProductOffer { + id + variant { + displayName + displayNameSubtype + displayNameTier + tierDescription + typeOfContract + perils { + title + description + colorCode + covered + info + } + documents { + type + displayName + url + } + } + cost { + gross { + ...MoneyFragment + } + net { + ...MoneyFragment + } + discountsV2 { + amount { + ...MoneyFragment + } + } + } + startDate + deductible { + displayName + amount + } + usps + exposure { + displayNameShort + } + bundleDiscount { + isEligible + potentialYearlySavings { + ...MoneyFragment + } + } +} diff --git a/app/feature/feature-purchase-house/src/main/graphql/HouseShopSessionCreateMutation.graphql b/app/feature/feature-purchase-house/src/main/graphql/HouseShopSessionCreateMutation.graphql new file mode 100644 index 0000000000..d739f2690e --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/graphql/HouseShopSessionCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation HouseShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} From ea100ed0c17f6954942694468434f057fc429b44 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:07:44 +0200 Subject: [PATCH 05/15] feat: add domain models for house purchase module --- .../house/data/HousePurchaseModels.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt new file mode 100644 index 0000000000..9d7f976648 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/HousePurchaseModels.kt @@ -0,0 +1,27 @@ +package com.hedvig.android.feature.purchase.house.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, + val ssn: String, + val email: String, +) + +internal data class HouseOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class HouseTierOffer( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossPrice: UiMoney, + val netPrice: UiMoney, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) From 3175eb4ad116dff89dd9ecd80680699169a2f81b Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:09:17 +0200 Subject: [PATCH 06/15] feat: add CreateHouseSessionAndPriceIntentUseCase --- ...CreateHouseSessionAndPriceIntentUseCase.kt | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt new file mode 100644 index 0000000000..6bde1fb7ad --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/CreateHouseSessionAndPriceIntentUseCase.kt @@ -0,0 +1,70 @@ +package com.hedvig.android.feature.purchase.house.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.HouseMemberContactInfoQuery +import octopus.HousePriceIntentCreateMutation +import octopus.HouseShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateHouseSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateHouseSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateHouseSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(HouseShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(HousePriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + val member = apolloClient + .query(HouseMemberContactInfoQuery()) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to fetch member contact info: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.currentMember }, + ) + val ssn = member.ssn + if (ssn == null) { + logcat(LogPriority.ERROR) { "Member is missing SSN — cannot continue house purchase" } + raise(ErrorMessage()) + } + + SessionAndIntent( + shopSessionId = shopSessionId, + priceIntentId = priceIntentId, + ssn = ssn, + email = member.email, + ) + } + } +} From cb8accbc9fda857c964ebcd995d17884c35bf782 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:15:49 +0200 Subject: [PATCH 07/15] feat: add SubmitVacationHomeFormAndGetOffersUseCase --- ...bmitVacationHomeFormAndGetOffersUseCase.kt | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt new file mode 100644 index 0000000000..b025d56fd1 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/data/SubmitVacationHomeFormAndGetOffersUseCase.kt @@ -0,0 +1,118 @@ +package com.hedvig.android.feature.purchase.house.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import octopus.HousePriceIntentConfirmMutation +import octopus.HousePriceIntentDataUpdateMutation +import octopus.fragment.HouseProductOfferFragment + +internal interface SubmitVacationHomeFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + ssn: String, + email: String, + street: String, + zipCode: String, + multipleOwners: Boolean, + yearOfConstruction: Int, + livingSpace: Int, + hasWaterConnected: Boolean, + numberOfBathrooms: Int, + isSubleted: Boolean, + ): Either +} + +internal class SubmitVacationHomeFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitVacationHomeFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + ssn: String, + email: String, + street: String, + zipCode: String, + multipleOwners: Boolean, + yearOfConstruction: Int, + livingSpace: Int, + hasWaterConnected: Boolean, + numberOfBathrooms: Int, + isSubleted: Boolean, + ): Either { + return either { + val formData = buildMap { + put("ssn", ssn) + put("email", email) + put("street", street) + put("zipCode", zipCode) + put("multipleOwners", multipleOwners) + put("yearOfConstruction", yearOfConstruction) + put("livingSpace", livingSpace) + put("hasWaterConnected", hasWaterConnected) + put("numberOfBathrooms", numberOfBathrooms) + put("isSubleted", isSubleted) + put("extraBuildings", emptyList>()) + } + + val updateResult = apolloClient + .mutation(HousePriceIntentDataUpdateMutation(priceIntentId = priceIntentId, data = formData)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to update price intent data: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentDataUpdate }, + ) + + if (updateResult.userError != null) { + raise(ErrorMessage(updateResult.userError?.message)) + } + + val confirmResult = apolloClient + .mutation(HousePriceIntentConfirmMutation(priceIntentId = priceIntentId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to confirm price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentConfirm }, + ) + + if (confirmResult.userError != null) { + raise(ErrorMessage(confirmResult.userError?.message)) + } + + val offers = confirmResult.priceIntent?.offers.orEmpty() + if (offers.isEmpty()) { + logcat(LogPriority.ERROR) { "No offers returned after confirming price intent" } + raise(ErrorMessage()) + } + + HouseOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toHouseTierOffer() }, + ) + } + } +} + +internal fun HouseProductOfferFragment.toHouseTierOffer(): HouseTierOffer { + return HouseTierOffer( + offerId = id, + tierDisplayName = variant.displayNameTier ?: variant.displayName, + tierDescription = variant.tierDescription ?: "", + grossPrice = UiMoney.fromMoneyFragment(cost.gross), + netPrice = UiMoney.fromMoneyFragment(cost.net), + usps = usps, + exposureDisplayName = exposure.displayNameShort, + deductibleDisplayName = deductible?.displayName, + hasDiscount = cost.net.amount < cost.gross.amount, + ) +} From 46363118a05a0538a421c5735d9c368b3a80c05c Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:17:52 +0200 Subject: [PATCH 08/15] feat: add VacationHomeFormViewModel --- .../vacationhome/VacationHomeFormViewModel.kt | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt new file mode 100644 index 0000000000..b3eda7c478 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt @@ -0,0 +1,249 @@ +package com.hedvig.android.feature.purchase.house.ui.vacationhome + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.house.data.HouseOffers +import com.hedvig.android.feature.purchase.house.data.SessionAndIntent +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import java.time.LocalDate + +internal class VacationHomeFormViewModel( + productName: String, + createHouseSessionAndPriceIntentUseCase: CreateHouseSessionAndPriceIntentUseCase, + submitVacationHomeFormAndGetOffersUseCase: SubmitVacationHomeFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = VacationHomeFormState(), + presenter = VacationHomeFormPresenter( + productName, + createHouseSessionAndPriceIntentUseCase, + submitVacationHomeFormAndGetOffersUseCase, + ), + ) + +internal sealed interface VacationHomeFormEvent { + data class SubmitForm( + val street: String, + val zipCode: String, + val multipleOwners: Boolean?, + val yearOfConstruction: String, + val livingSpace: String, + val hasWaterConnected: Boolean?, + val numberOfBathrooms: Int, + val isSubleted: Boolean?, + ) : VacationHomeFormEvent + + data object ClearNavigation : VacationHomeFormEvent + + data object Retry : VacationHomeFormEvent + + data object DismissError : VacationHomeFormEvent +} + +internal data class VacationHomeFormState( + val streetError: String? = null, + val zipCodeError: String? = null, + val multipleOwnersError: String? = null, + val yearOfConstructionError: String? = null, + val livingSpaceError: String? = null, + val hasWaterConnectedError: String? = null, + val isSubletedError: String? = null, + val isSubmitting: Boolean = false, + val isLoadingSession: Boolean = true, + val loadSessionError: Boolean = false, + val submitError: String? = null, + val offersToNavigate: OffersNavigationData? = null, +) + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: HouseOffers, +) + +private class VacationHomeFormPresenter( + private val productName: String, + private val createHouseSessionAndPriceIntentUseCase: CreateHouseSessionAndPriceIntentUseCase, + private val submitVacationHomeFormAndGetOffersUseCase: SubmitVacationHomeFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: VacationHomeFormState, + ): VacationHomeFormState { + var currentState by remember { mutableStateOf(lastState) } + var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } + var sessionLoadIteration by remember { mutableIntStateOf(0) } + var submitIteration by remember { mutableIntStateOf(0) } + var pendingSubmit: VacationHomeFormEvent.SubmitForm? by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is VacationHomeFormEvent.SubmitForm -> { + val errors = validate( + street = event.street, + zipCode = event.zipCode, + multipleOwners = event.multipleOwners, + yearOfConstruction = event.yearOfConstruction, + livingSpace = event.livingSpace, + hasWaterConnected = event.hasWaterConnected, + isSubleted = event.isSubleted, + ) + if (errors.hasErrors()) { + currentState = currentState.copy( + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + multipleOwnersError = errors.multipleOwnersError, + yearOfConstructionError = errors.yearOfConstructionError, + livingSpaceError = errors.livingSpaceError, + hasWaterConnectedError = errors.hasWaterConnectedError, + isSubletedError = errors.isSubletedError, + ) + } else { + currentState = currentState.copy( + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + ) + pendingSubmit = event + submitIteration++ + } + } + + VacationHomeFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + + VacationHomeFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + + VacationHomeFormEvent.DismissError -> { + currentState = currentState.copy(submitError = null) + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createHouseSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + val submit = pendingSubmit ?: return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + val multipleOwners = submit.multipleOwners ?: return@LaunchedEffect + val yearOfConstruction = submit.yearOfConstruction.toIntOrNull() ?: return@LaunchedEffect + val livingSpace = submit.livingSpace.toIntOrNull() ?: return@LaunchedEffect + val hasWaterConnected = submit.hasWaterConnected ?: return@LaunchedEffect + val isSubleted = submit.isSubleted ?: return@LaunchedEffect + pendingSubmit = null + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitVacationHomeFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + ssn = session.ssn, + email = session.email, + street = submit.street, + zipCode = submit.zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = submit.numberOfBathrooms, + isSubleted = isSubleted, + ).fold( + ifLeft = { error -> + currentState = currentState.copy( + isSubmitting = false, + submitError = error.message ?: "Something went wrong", + ) + }, + ifRight = { offers -> + currentState = currentState.copy( + isSubmitting = false, + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ), + ) + }, + ) + } + + return currentState + } +} + +private data class ValidationErrors( + val streetError: String?, + val zipCodeError: String?, + val multipleOwnersError: String?, + val yearOfConstructionError: String?, + val livingSpaceError: String?, + val hasWaterConnectedError: String?, + val isSubletedError: String?, +) { + fun hasErrors(): Boolean = streetError != null || + zipCodeError != null || + multipleOwnersError != null || + yearOfConstructionError != null || + livingSpaceError != null || + hasWaterConnectedError != null || + isSubletedError != null +} + +private fun validate( + street: String, + zipCode: String, + multipleOwners: Boolean?, + yearOfConstruction: String, + livingSpace: String, + hasWaterConnected: Boolean?, + isSubleted: Boolean?, +): ValidationErrors { + val currentYear = LocalDate.now().year + return ValidationErrors( + streetError = if (street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + else -> null + }, + multipleOwnersError = if (multipleOwners == null) "Välj ett alternativ" else null, + yearOfConstructionError = when (val year = yearOfConstruction.toIntOrNull()) { + null -> "Ange byggår" + !in 1700..currentYear -> "Ange ett giltigt byggår" + else -> null + }, + livingSpaceError = when (val space = livingSpace.toIntOrNull()) { + null -> "Ange boyta" + !in 1..Int.MAX_VALUE -> "Ange en giltig boyta" + else -> null + }, + hasWaterConnectedError = if (hasWaterConnected == null) "Välj ett alternativ" else null, + isSubletedError = if (isSubleted == null) "Välj ett alternativ" else null, + ) +} From aa6d14a8b774d7b3c98d6555200f91ded9e5feeb Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:21:04 +0200 Subject: [PATCH 09/15] feat: add VacationHomeFormDestination with Compose previews --- .../VacationHomeFormDestination.kt | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt new file mode 100644 index 0000000000..a1cdd0eeec --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt @@ -0,0 +1,454 @@ +package com.hedvig.android.feature.purchase.house.ui.vacationhome + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ErrorDialog +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigStepper +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioGroupStyle +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperSize.Medium +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperStyle.Labeled +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.purchase.house.data.HouseOffers + +@Composable +internal fun VacationHomeFormDestination( + viewModel: VacationHomeFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: HouseOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(VacationHomeFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + ) { + when { + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } + + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(VacationHomeFormEvent.Retry) }, + ) + } + + else -> { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var multipleOwners by remember { mutableStateOf(null) } + var yearOfConstruction by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var hasWaterConnected by remember { mutableStateOf(null) } + var numberOfBathrooms by remember { mutableIntStateOf(1) } + var isSubleted by remember { mutableStateOf(null) } + + if (uiState.submitError != null) { + ErrorDialog( + title = "Något gick fel", + message = uiState.submitError, + onDismiss = { viewModel.emit(VacationHomeFormEvent.DismissError) }, + ) + } + VacationHomeFormContent( + street = street, + zipCode = zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = numberOfBathrooms, + isSubleted = isSubleted, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + multipleOwnersError = uiState.multipleOwnersError, + yearOfConstructionError = uiState.yearOfConstructionError, + livingSpaceError = uiState.livingSpaceError, + hasWaterConnectedError = uiState.hasWaterConnectedError, + isSubletedError = uiState.isSubletedError, + isSubmitting = uiState.isSubmitting, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() } && value.length <= 5) zipCode = value }, + onMultipleOwnersChanged = { multipleOwners = it }, + onYearOfConstructionChanged = { value -> + if (value.isEmpty() || (value.all { it.isDigit() } && value.length <= 4)) yearOfConstruction = value + }, + onLivingSpaceChanged = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value + }, + onHasWaterConnectedChanged = { hasWaterConnected = it }, + onNumberOfBathroomsChanged = { numberOfBathrooms = it }, + onIsSubletedChanged = { isSubleted = it }, + onSubmit = { + viewModel.emit( + VacationHomeFormEvent.SubmitForm( + street = street, + zipCode = zipCode, + multipleOwners = multipleOwners, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + hasWaterConnected = hasWaterConnected, + numberOfBathrooms = numberOfBathrooms, + isSubleted = isSubleted, + ), + ) + }, + ) + } + } + } +} + +@Composable +private fun VacationHomeFormContent( + street: String, + zipCode: String, + multipleOwners: Boolean?, + yearOfConstruction: String, + livingSpace: String, + hasWaterConnected: Boolean?, + numberOfBathrooms: Int, + isSubleted: Boolean?, + streetError: String?, + zipCodeError: String?, + multipleOwnersError: String?, + yearOfConstructionError: String?, + livingSpaceError: String?, + hasWaterConnectedError: String?, + isSubletedError: String?, + isSubmitting: Boolean, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onMultipleOwnersChanged: (Boolean) -> Unit, + onYearOfConstructionChanged: (String) -> Unit, + onLivingSpaceChanged: (String) -> Unit, + onHasWaterConnectedChanged: (Boolean) -> Unit, + onNumberOfBathroomsChanged: (Int) -> Unit, + onIsSubletedChanged: (Boolean) -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + // TODO: Add "Fill in your details and we'll calculate your price" / "Fyll i dina uppgifter så beräknar vi ditt pris" to Lokalise + HedvigText( + text = "Fyll i dina uppgifter så beräknar vi ditt pris", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // TODO: Add "Address" / "Adress" to Lokalise + HedvigTextField( + text = street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, + ) + // TODO: Add "Postal code" / "Postnummer" to Lokalise + HedvigTextField( + text = zipCode, + onValueChange = onZipCodeChanged, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Do you own the house with someone else?" / "Äger du huset tillsammans med någon annan?" to Lokalise + HedvigText( + text = "Äger du huset tillsammans med någon annan?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = multipleOwners, + onSelectionChanged = onMultipleOwnersChanged, + enabled = !isSubmitting, + errorText = multipleOwnersError, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Year built" / "Byggår" to Lokalise + HedvigTextField( + text = yearOfConstruction, + onValueChange = onYearOfConstructionChanged, + labelText = "Byggår", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = yearOfConstructionError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + // TODO: Add "Living space (m²)" / "Boyta (kvm)" to Lokalise + HedvigTextField( + text = livingSpace, + onValueChange = onLivingSpaceChanged, + labelText = "Boyta (kvm)", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = livingSpaceError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !isSubmitting, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Is water connected?" / "Är vatten anslutet?" to Lokalise + HedvigText( + text = "Är vatten anslutet?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = hasWaterConnected, + onSelectionChanged = onHasWaterConnectedChanged, + enabled = !isSubmitting, + errorText = hasWaterConnectedError, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Number of bathrooms" / "Antal badrum" to Lokalise + HedvigStepper( + text = when (numberOfBathrooms) { + 1 -> "1 badrum" + else -> "$numberOfBathrooms badrum" + }, + stepperSize = Medium, + stepperStyle = Labeled("Antal badrum"), + onMinusClick = { onNumberOfBathroomsChanged(numberOfBathrooms - 1) }, + onPlusClick = { onNumberOfBathroomsChanged(numberOfBathrooms + 1) }, + isPlusEnabled = !isSubmitting && numberOfBathrooms < 10, + isMinusEnabled = !isSubmitting && numberOfBathrooms > 1, + ) + + Spacer(Modifier.height(8.dp)) + // TODO: Add "Do you sublet all or parts of the house?" / "Hyr du ut hela eller delar av huset?" to Lokalise + HedvigText( + text = "Hyr du ut hela eller delar av huset?", + style = HedvigTheme.typography.bodyMedium, + ) + YesNoRadio( + selected = isSubleted, + onSelectionChanged = onIsSubletedChanged, + enabled = !isSubmitting, + errorText = isSubletedError, + ) + } + Spacer(Modifier.height(16.dp)) + // TODO: Add "Calculate price" / "Beräkna pris" to Lokalise + HedvigButton( + text = "Beräkna pris", + onClick = onSubmit, + enabled = !isSubmitting, + isLoading = isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +private const val OPTION_YES = "YES" +private const val OPTION_NO = "NO" + +@Composable +private fun YesNoRadio( + selected: Boolean?, + onSelectionChanged: (Boolean) -> Unit, + enabled: Boolean, + errorText: String?, +) { + val options = listOf( + // TODO: Add "Yes" / "Ja" to Lokalise + RadioOption(id = RadioOptionId(OPTION_YES), text = "Ja"), + // TODO: Add "No" / "Nej" to Lokalise + RadioOption(id = RadioOptionId(OPTION_NO), text = "Nej"), + ) + val selectedId = when (selected) { + true -> RadioOptionId(OPTION_YES) + false -> RadioOptionId(OPTION_NO) + null -> null + } + RadioGroup( + options = options, + selectedOption = selectedId, + onRadioOptionSelected = { id -> + onSelectionChanged(id == RadioOptionId(OPTION_YES)) + }, + style = RadioGroupStyle.Horizontal, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + if (errorText != null) { + HedvigText( + text = errorText, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.signalRedElement, + ) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "", + zipCode = "", + multipleOwners = null, + yearOfConstruction = "", + livingSpace = "", + hasWaterConnected = null, + numberOfBathrooms = 1, + isSubleted = null, + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormFilled() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "Storgatan 1", + zipCode = "12345", + multipleOwners = false, + yearOfConstruction = "1985", + livingSpace = "60", + hasWaterConnected = true, + numberOfBathrooms = 1, + isSubleted = false, + streetError = null, + zipCodeError = null, + multipleOwnersError = null, + yearOfConstructionError = null, + livingSpaceError = null, + hasWaterConnectedError = null, + isSubletedError = null, + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewVacationHomeFormErrors() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + VacationHomeFormContent( + street = "", + zipCode = "12", + multipleOwners = null, + yearOfConstruction = "1500", + livingSpace = "", + hasWaterConnected = null, + numberOfBathrooms = 1, + isSubleted = null, + streetError = "Ange en adress", + zipCodeError = "Ange ett giltigt postnummer (5 siffror)", + multipleOwnersError = "Välj ett alternativ", + yearOfConstructionError = "Ange ett giltigt byggår", + livingSpaceError = "Ange boyta", + hasWaterConnectedError = "Välj ett alternativ", + isSubletedError = "Välj ett alternativ", + isSubmitting = false, + onStreetChanged = {}, + onZipCodeChanged = {}, + onMultipleOwnersChanged = {}, + onYearOfConstructionChanged = {}, + onLivingSpaceChanged = {}, + onHasWaterConnectedChanged = {}, + onNumberOfBathroomsChanged = {}, + onIsSubletedChanged = {}, + onSubmit = {}, + ) + } + } +} From e2b5fb089633813b14789b1aecb07086b56f2bc2 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:24:17 +0200 Subject: [PATCH 10/15] feat: add navigation graph for house purchase module --- .../navigation/HousePurchaseDestination.kt | 14 ++ .../house/navigation/HousePurchaseNavGraph.kt | 120 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseDestination.kt create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseNavGraph.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseDestination.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseDestination.kt new file mode 100644 index 0000000000..cf08cab255 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseDestination.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.feature.purchase.house.navigation + +import com.hedvig.android.navigation.common.Destination +import kotlinx.serialization.Serializable + +@Serializable +data class HousePurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface HousePurchaseDestination { + @Serializable + data object Form : HousePurchaseDestination, Destination +} diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseNavGraph.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseNavGraph.kt new file mode 100644 index 0000000000..b2d6710ae5 --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/navigation/HousePurchaseNavGraph.kt @@ -0,0 +1,120 @@ +package com.hedvig.android.feature.purchase.house.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.toRoute +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.data.cross.sell.after.flow.CrossSellInfoType +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.SelectTier +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Signing +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Success +import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination.Summary +import com.hedvig.android.feature.purchase.common.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.common.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.common.navigation.TierOfferData +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.common.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.common.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.common.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.common.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.feature.purchase.house.navigation.HousePurchaseDestination.Form +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormDestination +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typed.getRouteFromBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.housePurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: VacationHomeFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + VacationHomeFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = { shopSessionId, offers -> + navController.navigate( + SelectTier( + SelectTierParameters( + shopSessionId = shopSessionId, + offers = offers.offers.map { offer -> + TierOfferData( + offerId = offer.offerId, + tierDisplayName = offer.tierDisplayName, + tierDescription = offer.tierDescription, + grossAmount = offer.grossPrice.amount, + grossCurrencyCode = offer.grossPrice.currencyCode.name, + netAmount = offer.netPrice.amount, + netCurrencyCode = offer.netPrice.currencyCode.name, + usps = offer.usps, + exposureDisplayName = offer.exposureDisplayName, + deductibleDisplayName = offer.deductibleDisplayName, + hasDiscount = offer.hasDiscount, + ) + }, + productDisplayName = offers.productDisplayName, + ), + ), + ) + }, + ) + } + + navdestination(SelectTier) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(route.params) + } + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = { params -> navController.navigate(Summary(params)) }, + ) + } + + navdestination(Summary) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(route.params) + } + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + navigateToSigning = { params -> navController.navigate(Signing(params)) }, + ) + } + + navdestination(Signing) { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: SigningViewModel = koinViewModel { + parametersOf(route.params) + } + SigningDestination( + viewModel = viewModel, + navigateToSuccess = { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + ) + } + } +} From c9a95d4eb1da785887119cc2fd40b995fea73b27 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:25:46 +0200 Subject: [PATCH 11/15] feat: add Koin module for house purchase --- .../purchase/house/di/HousePurchaseModule.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt new file mode 100644 index 0000000000..6dd1fc743a --- /dev/null +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/di/HousePurchaseModule.kt @@ -0,0 +1,26 @@ +package com.hedvig.android.feature.purchase.house.di + +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.house.data.CreateHouseSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.house.data.SubmitVacationHomeFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.house.ui.vacationhome.VacationHomeFormViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val housePurchaseModule = module { + single { + CreateHouseSessionAndPriceIntentUseCaseImpl(apolloClient = get()) + } + single { + SubmitVacationHomeFormAndGetOffersUseCaseImpl(apolloClient = get()) + } + + viewModel { params -> + VacationHomeFormViewModel( + productName = params.get(), + createHouseSessionAndPriceIntentUseCase = get(), + submitVacationHomeFormAndGetOffersUseCase = get(), + ) + } +} From a746d3198e2bd0e8f482e348ea6c9d1affe027e7 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 08:36:34 +0200 Subject: [PATCH 12/15] feat: route fritidshus cross-sells to in-app purchase flow --- .../android/feature/insurances/navigation/InsuranceGraph.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt index 98117cceb8..bbc65ee60b 100644 --- a/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt +++ b/app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt @@ -44,6 +44,7 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToApartmentPurchase: (productName: String) -> Unit, onNavigateToCarPurchase: (productName: String) -> Unit, onNavigateToPetPurchase: () -> Unit, + onNavigateToHousePurchase: (productName: String) -> Unit, ) { navgraph( startDestination = InsurancesDestination.Insurances::class, @@ -71,6 +72,10 @@ fun NavGraphBuilder.insuranceGraph( } val lower = decoded.lowercase() when { + "fritidshusforsakring" in lower || "vacation-home" in lower -> { + onNavigateToHousePurchase("SE_VACATION_HOME") + } + "car-insurance" in lower || "bilforsakring" in lower -> { onNavigateToCarPurchase("SE_CAR") } From 9d2bddd98ee8e0f3afac9bd05cb9a2c42a292aa2 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 09:39:27 +0200 Subject: [PATCH 13/15] feat: integrate house purchase flow into main app navigation --- .../com/hedvig/android/app/di/ApplicationModule.kt | 2 ++ .../hedvig/android/app/navigation/HedvigNavHost.kt | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt index b8a9c2000d..190128570c 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt @@ -82,6 +82,7 @@ import com.hedvig.android.feature.profile.di.profileModule import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule import com.hedvig.android.feature.purchase.car.di.carPurchaseModule import com.hedvig.android.feature.purchase.common.di.purchaseCommonModule +import com.hedvig.android.feature.purchase.house.di.housePurchaseModule import com.hedvig.android.feature.purchase.pet.di.petPurchaseModule import com.hedvig.android.feature.terminateinsurance.di.terminateInsuranceModule import com.hedvig.android.feature.travelcertificate.di.travelCertificateModule @@ -296,6 +297,7 @@ val applicationModule = module { addonRemovalModule, apartmentPurchaseModule, carPurchaseModule, + housePurchaseModule, petPurchaseModule, androidPermissionModule, apolloAuthListenersModule, diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt index 7ecbcb85fe..7cd44cf6dd 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt @@ -77,6 +77,8 @@ import com.hedvig.android.feature.purchase.car.navigation.CarPurchaseGraphDestin import com.hedvig.android.feature.purchase.car.navigation.carPurchaseNavGraph import com.hedvig.android.feature.purchase.common.navigation.PurchaseCommonDestination import com.hedvig.android.feature.purchase.common.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.house.navigation.HousePurchaseGraphDestination +import com.hedvig.android.feature.purchase.house.navigation.housePurchaseNavGraph import com.hedvig.android.feature.purchase.pet.navigation.PetPurchaseGraphDestination import com.hedvig.android.feature.purchase.pet.navigation.petPurchaseNavGraph import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceGraphDestination @@ -342,6 +344,9 @@ internal fun HedvigNavHost( onNavigateToPetPurchase = { navController.navigate(PetPurchaseGraphDestination) }, + onNavigateToHousePurchase = { productName -> + navController.navigate(HousePurchaseGraphDestination(productName)) + }, ) foreverGraph( hedvigDeepLinkContainer = hedvigDeepLinkContainer, @@ -515,6 +520,12 @@ internal fun HedvigNavHost( finishApp = finishApp, crossSellAfterFlowRepository = crossSellAfterFlowRepository, ) + housePurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) navdestination { backStackEntry -> val route = backStackEntry.toRoute() PurchaseSuccessDestination( From 37efccf1c0a02add974088369f59c942b04d8621 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 09:40:52 +0200 Subject: [PATCH 14/15] fix: add featurePurchaseHouse dependency to app module Required for the housePurchaseModule and housePurchaseNavGraph wired in the previous commit. sort-dependencies plugin alphabetized the file as a side effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/build.gradle.kts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 730092dc49..7d3920c9e0 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -157,16 +157,15 @@ dependencies { implementation(projects.coreBuildConstants) implementation(projects.coreCommonPublic) implementation(projects.coreDatastorePublic) - implementation(projects.coreRive) implementation(projects.coreDemoMode) implementation(projects.coreFileUpload) implementation(projects.coreIcons) implementation(projects.coreResources) + implementation(projects.coreRive) implementation(projects.crossSells) implementation(projects.dataAddons) implementation(projects.dataChangetier) implementation(projects.dataChat) - implementation(projects.dataContract) implementation(projects.dataConversations) implementation(projects.dataCrossSellAfterClaimClosed) @@ -185,6 +184,7 @@ dependencies { implementation(projects.featureAddonPurchase) implementation(projects.featurePurchaseApartment) implementation(projects.featurePurchaseCar) + implementation(projects.featurePurchaseHouse) implementation(projects.featurePurchasePet) implementation(projects.purchaseCommon) implementation(projects.featureChat) @@ -192,7 +192,6 @@ dependencies { implementation(projects.featureClaimChat) implementation(projects.featureClaimDetails) implementation(projects.featureClaimHistory) - implementation(projects.featureConnectPaymentTrustly) implementation(projects.featureCrossSellSheet) implementation(projects.featureDeleteAccount) @@ -207,10 +206,12 @@ dependencies { implementation(projects.featureInsurances) implementation(projects.featureLogin) implementation(projects.featureMovingflow) - - implementation(projects.featureRemoveAddons) implementation(projects.featurePayments) implementation(projects.featureProfile) + implementation(projects.featurePurchaseApartment) + implementation(projects.featurePurchaseCar) + implementation(projects.featurePurchaseHouse) + implementation(projects.featureRemoveAddons) implementation(projects.featureTerminateInsurance) implementation(projects.featureTravelCertificate) implementation(projects.foreverUi) @@ -220,7 +221,6 @@ dependencies { implementation(projects.languageMigration) implementation(projects.loggingDeviceModel) implementation(projects.loggingPublic) - implementation(projects.permissionCore) implementation(projects.memberRemindersPublic) implementation(projects.navigationActivity) implementation(projects.navigationCommon) @@ -230,6 +230,8 @@ dependencies { implementation(projects.notificationBadgeDataPublic) implementation(projects.notificationCore) implementation(projects.notificationFirebase) + implementation(projects.permissionCore) + implementation(projects.purchaseCommon) implementation(projects.shareddi) implementation(projects.theme) implementation(projects.tierComparison) @@ -237,13 +239,13 @@ dependencies { implementation(projects.trackingDatadog) implementation(projects.uiForceUpgrade) + debugImplementation(libs.androidx.compose.uiTooling) + debugImplementation(projects.featureImpersonation) + // OkHttp for ProGuard rules only - not available at compile time runtimeOnly(platform(libs.okhttp.bom)) runtimeOnly(libs.okhttp.core) - debugImplementation(libs.androidx.compose.uiTooling) - debugImplementation(projects.featureImpersonation) - debugRuntimeOnly(libs.androidx.compose.uiTestManifest) } From de584b914602fcc41b51cfce152e64eec5a83018 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Thu, 21 May 2026 11:28:57 +0200 Subject: [PATCH 15/15] refactor: align vacation home form with pet form pattern - Use RadioChoiceRow with RadioGroupStyle.Labeled.HorizontalFlow label - Add UpdateMultipleOwners/UpdateHasWaterConnected/UpdateIsSubleted events so radio values live in ViewModel state, clear their own error on change - Switch text fields to rememberSaveable for config-change survival - yesNoOptions() helper shared across the three boolean radios - English copy primary with Lokalise TODO comments (matches pet) - onEvent(VacationHomeFormEvent) handler replaces N individual callbacks Co-Authored-By: Claude Opus 4.7 (1M context) --- .../VacationHomeFormDestination.kt | 369 ++++++++---------- .../vacationhome/VacationHomeFormViewModel.kt | 116 +++--- 2 files changed, 221 insertions(+), 264 deletions(-) diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt index a1cdd0eeec..829799a03b 100644 --- a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormDestination.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -53,123 +53,103 @@ internal fun VacationHomeFormDestination( onOffersReceived(offersData.shopSessionId, offersData.offers) } } - HedvigScaffold( - navigateUp = navigateUp, - ) { + HedvigScaffold(navigateUp = navigateUp) { when { - uiState.isLoadingSession -> { - HedvigFullScreenCenterAlignedProgress() - } + uiState.isLoadingSession -> HedvigFullScreenCenterAlignedProgress() - uiState.loadSessionError -> { - HedvigErrorSection( - onButtonClick = { viewModel.emit(VacationHomeFormEvent.Retry) }, - ) - } + uiState.loadSessionError -> HedvigErrorSection( + onButtonClick = { viewModel.emit(VacationHomeFormEvent.Retry) }, + ) + + else -> VacationHomeFormBody( + uiState = uiState, + onEvent = { event -> viewModel.emit(event) }, + ) + } + } +} + +@Composable +private fun VacationHomeFormBody(uiState: VacationHomeFormState, onEvent: (VacationHomeFormEvent) -> Unit) { + var street by rememberSaveable { mutableStateOf("") } + var zipCode by rememberSaveable { mutableStateOf("") } + var yearOfConstruction by rememberSaveable { mutableStateOf("") } + var livingSpace by rememberSaveable { mutableStateOf("") } + var numberOfBathrooms by rememberSaveable { mutableIntStateOf(1) } - else -> { - var street by remember { mutableStateOf("") } - var zipCode by remember { mutableStateOf("") } - var multipleOwners by remember { mutableStateOf(null) } - var yearOfConstruction by remember { mutableStateOf("") } - var livingSpace by remember { mutableStateOf("") } - var hasWaterConnected by remember { mutableStateOf(null) } - var numberOfBathrooms by remember { mutableIntStateOf(1) } - var isSubleted by remember { mutableStateOf(null) } + if (uiState.submitError != null) { + ErrorDialog( + // TODO: Add "Something went wrong" / "Något gick fel" to Lokalise + title = "Something went wrong", + message = uiState.submitError, + onDismiss = { onEvent(VacationHomeFormEvent.DismissError) }, + ) + } - if (uiState.submitError != null) { - ErrorDialog( - title = "Något gick fel", - message = uiState.submitError, - onDismiss = { viewModel.emit(VacationHomeFormEvent.DismissError) }, - ) - } - VacationHomeFormContent( + VacationHomeFormContent( + street = street, + zipCode = zipCode, + yearOfConstruction = yearOfConstruction, + livingSpace = livingSpace, + numberOfBathrooms = numberOfBathrooms, + multipleOwners = uiState.multipleOwners, + hasWaterConnected = uiState.hasWaterConnected, + isSubleted = uiState.isSubleted, + errors = uiState, + isSubmitting = uiState.isSubmitting, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() } && value.length <= 5) zipCode = value }, + onYearOfConstructionChanged = { value -> + if (value.isEmpty() || (value.all { it.isDigit() } && value.length <= 4)) yearOfConstruction = value + }, + onLivingSpaceChanged = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value + }, + onNumberOfBathroomsChanged = { numberOfBathrooms = it }, + onMultipleOwnersSelected = { onEvent(VacationHomeFormEvent.UpdateMultipleOwners(it)) }, + onHasWaterConnectedSelected = { onEvent(VacationHomeFormEvent.UpdateHasWaterConnected(it)) }, + onIsSubletedSelected = { onEvent(VacationHomeFormEvent.UpdateIsSubleted(it)) }, + onSubmit = { + onEvent( + VacationHomeFormEvent.SubmitForm( street = street, zipCode = zipCode, - multipleOwners = multipleOwners, yearOfConstruction = yearOfConstruction, livingSpace = livingSpace, - hasWaterConnected = hasWaterConnected, numberOfBathrooms = numberOfBathrooms, - isSubleted = isSubleted, - streetError = uiState.streetError, - zipCodeError = uiState.zipCodeError, - multipleOwnersError = uiState.multipleOwnersError, - yearOfConstructionError = uiState.yearOfConstructionError, - livingSpaceError = uiState.livingSpaceError, - hasWaterConnectedError = uiState.hasWaterConnectedError, - isSubletedError = uiState.isSubletedError, - isSubmitting = uiState.isSubmitting, - onStreetChanged = { street = it }, - onZipCodeChanged = { value -> if (value.all { it.isDigit() } && value.length <= 5) zipCode = value }, - onMultipleOwnersChanged = { multipleOwners = it }, - onYearOfConstructionChanged = { value -> - if (value.isEmpty() || (value.all { it.isDigit() } && value.length <= 4)) yearOfConstruction = value - }, - onLivingSpaceChanged = { value -> - if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value - }, - onHasWaterConnectedChanged = { hasWaterConnected = it }, - onNumberOfBathroomsChanged = { numberOfBathrooms = it }, - onIsSubletedChanged = { isSubleted = it }, - onSubmit = { - viewModel.emit( - VacationHomeFormEvent.SubmitForm( - street = street, - zipCode = zipCode, - multipleOwners = multipleOwners, - yearOfConstruction = yearOfConstruction, - livingSpace = livingSpace, - hasWaterConnected = hasWaterConnected, - numberOfBathrooms = numberOfBathrooms, - isSubleted = isSubleted, - ), - ) - }, - ) - } - } - } + ), + ) + }, + ) } @Composable private fun VacationHomeFormContent( street: String, zipCode: String, - multipleOwners: Boolean?, yearOfConstruction: String, livingSpace: String, - hasWaterConnected: Boolean?, numberOfBathrooms: Int, + multipleOwners: Boolean?, + hasWaterConnected: Boolean?, isSubleted: Boolean?, - streetError: String?, - zipCodeError: String?, - multipleOwnersError: String?, - yearOfConstructionError: String?, - livingSpaceError: String?, - hasWaterConnectedError: String?, - isSubletedError: String?, + errors: VacationHomeFormState, isSubmitting: Boolean, onStreetChanged: (String) -> Unit, onZipCodeChanged: (String) -> Unit, - onMultipleOwnersChanged: (Boolean) -> Unit, onYearOfConstructionChanged: (String) -> Unit, onLivingSpaceChanged: (String) -> Unit, - onHasWaterConnectedChanged: (Boolean) -> Unit, onNumberOfBathroomsChanged: (Int) -> Unit, - onIsSubletedChanged: (Boolean) -> Unit, + onMultipleOwnersSelected: (Boolean) -> Unit, + onHasWaterConnectedSelected: (Boolean) -> Unit, + onIsSubletedSelected: (Boolean) -> Unit, onSubmit: () -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Spacer(Modifier.height(16.dp)) - // TODO: Add "Fill in your details and we'll calculate your price" / "Fyll i dina uppgifter så beräknar vi ditt pris" to Lokalise HedvigText( - text = "Fyll i dina uppgifter så beräknar vi ditt pris", + // TODO: Add "Fill in your details so we can calculate your price" / "Fyll i dina uppgifter så beräknar vi ditt pris" to Lokalise + text = "Fill in your details so we can calculate your price", style = HedvigTheme.typography.bodyMedium, color = HedvigTheme.colorScheme.textSecondary, ) @@ -178,23 +158,23 @@ private fun VacationHomeFormContent( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - // TODO: Add "Address" / "Adress" to Lokalise HedvigTextField( text = street, onValueChange = onStreetChanged, - labelText = "Adress", + // TODO: Add "Address" / "Adress" to Lokalise + labelText = "Address", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = streetError.toErrorState(), + errorState = errors.streetError.toErrorState(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), enabled = !isSubmitting, ) - // TODO: Add "Postal code" / "Postnummer" to Lokalise HedvigTextField( text = zipCode, onValueChange = onZipCodeChanged, - labelText = "Postnummer", + // TODO: Add "Zip code" / "Postnummer" to Lokalise + labelText = "Zip code", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = zipCodeError.toErrorState(), + errorState = errors.zipCodeError.toErrorState(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next, @@ -202,40 +182,36 @@ private fun VacationHomeFormContent( enabled = !isSubmitting, ) - Spacer(Modifier.height(8.dp)) // TODO: Add "Do you own the house with someone else?" / "Äger du huset tillsammans med någon annan?" to Lokalise - HedvigText( - text = "Äger du huset tillsammans med någon annan?", - style = HedvigTheme.typography.bodyMedium, - ) - YesNoRadio( - selected = multipleOwners, - onSelectionChanged = onMultipleOwnersChanged, - enabled = !isSubmitting, - errorText = multipleOwnersError, + RadioChoiceRow( + label = "Do you own the house with someone else?", + selectedId = multipleOwners?.toString(), + options = yesNoOptions(), + onSelected = { id -> onMultipleOwnersSelected(id.toBoolean()) }, + errorText = errors.multipleOwnersError, + isEnabled = !isSubmitting, ) - Spacer(Modifier.height(8.dp)) - // TODO: Add "Year built" / "Byggår" to Lokalise HedvigTextField( text = yearOfConstruction, onValueChange = onYearOfConstructionChanged, - labelText = "Byggår", + // TODO: Add "Year of construction" / "Byggår" to Lokalise + labelText = "Year of construction", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = yearOfConstructionError.toErrorState(), + errorState = errors.yearOfConstructionError.toErrorState(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next, ), enabled = !isSubmitting, ) - // TODO: Add "Living space (m²)" / "Boyta (kvm)" to Lokalise HedvigTextField( text = livingSpace, onValueChange = onLivingSpaceChanged, - labelText = "Boyta (kvm)", + // TODO: Add "Living space (m²)" / "Boyta (kvm)" to Lokalise + labelText = "Living space (m²)", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = livingSpaceError.toErrorState(), + errorState = errors.livingSpaceError.toErrorState(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next, @@ -243,51 +219,43 @@ private fun VacationHomeFormContent( enabled = !isSubmitting, ) - Spacer(Modifier.height(8.dp)) // TODO: Add "Is water connected?" / "Är vatten anslutet?" to Lokalise - HedvigText( - text = "Är vatten anslutet?", - style = HedvigTheme.typography.bodyMedium, - ) - YesNoRadio( - selected = hasWaterConnected, - onSelectionChanged = onHasWaterConnectedChanged, - enabled = !isSubmitting, - errorText = hasWaterConnectedError, + RadioChoiceRow( + label = "Is water connected?", + selectedId = hasWaterConnected?.toString(), + options = yesNoOptions(), + onSelected = { id -> onHasWaterConnectedSelected(id.toBoolean()) }, + errorText = errors.hasWaterConnectedError, + isEnabled = !isSubmitting, ) - Spacer(Modifier.height(8.dp)) // TODO: Add "Number of bathrooms" / "Antal badrum" to Lokalise + // TODO: Add "1 bathroom" / "1 badrum" to Lokalise (singular) + // TODO: Add "{count} bathrooms" / "{count} badrum" to Lokalise (plural) HedvigStepper( - text = when (numberOfBathrooms) { - 1 -> "1 badrum" - else -> "$numberOfBathrooms badrum" - }, + text = if (numberOfBathrooms == 1) "1 bathroom" else "$numberOfBathrooms bathrooms", stepperSize = Medium, - stepperStyle = Labeled("Antal badrum"), + stepperStyle = Labeled("Number of bathrooms"), onMinusClick = { onNumberOfBathroomsChanged(numberOfBathrooms - 1) }, onPlusClick = { onNumberOfBathroomsChanged(numberOfBathrooms + 1) }, isPlusEnabled = !isSubmitting && numberOfBathrooms < 10, isMinusEnabled = !isSubmitting && numberOfBathrooms > 1, ) - Spacer(Modifier.height(8.dp)) // TODO: Add "Do you sublet all or parts of the house?" / "Hyr du ut hela eller delar av huset?" to Lokalise - HedvigText( - text = "Hyr du ut hela eller delar av huset?", - style = HedvigTheme.typography.bodyMedium, - ) - YesNoRadio( - selected = isSubleted, - onSelectionChanged = onIsSubletedChanged, - enabled = !isSubmitting, - errorText = isSubletedError, + RadioChoiceRow( + label = "Do you sublet all or parts of the house?", + selectedId = isSubleted?.toString(), + options = yesNoOptions(), + onSelected = { id -> onIsSubletedSelected(id.toBoolean()) }, + errorText = errors.isSubletedError, + isEnabled = !isSubmitting, ) } Spacer(Modifier.height(16.dp)) - // TODO: Add "Calculate price" / "Beräkna pris" to Lokalise HedvigButton( - text = "Beräkna pris", + // TODO: Add "Calculate price" / "Beräkna pris" to Lokalise + text = "Calculate price", onClick = onSubmit, enabled = !isSubmitting, isLoading = isSubmitting, @@ -297,46 +265,39 @@ private fun VacationHomeFormContent( } } -private const val OPTION_YES = "YES" -private const val OPTION_NO = "NO" - @Composable -private fun YesNoRadio( - selected: Boolean?, - onSelectionChanged: (Boolean) -> Unit, - enabled: Boolean, +private fun RadioChoiceRow( + label: String, + selectedId: String?, + options: List>, + onSelected: (String) -> Unit, errorText: String?, + isEnabled: Boolean, ) { - val options = listOf( - // TODO: Add "Yes" / "Ja" to Lokalise - RadioOption(id = RadioOptionId(OPTION_YES), text = "Ja"), - // TODO: Add "No" / "Nej" to Lokalise - RadioOption(id = RadioOptionId(OPTION_NO), text = "Nej"), - ) - val selectedId = when (selected) { - true -> RadioOptionId(OPTION_YES) - false -> RadioOptionId(OPTION_NO) - null -> null - } - RadioGroup( - options = options, - selectedOption = selectedId, - onRadioOptionSelected = { id -> - onSelectionChanged(id == RadioOptionId(OPTION_YES)) - }, - style = RadioGroupStyle.Horizontal, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - ) - if (errorText != null) { - HedvigText( - text = errorText, - style = HedvigTheme.typography.label, - color = HedvigTheme.colorScheme.signalRedElement, + Column(modifier = Modifier.fillMaxWidth()) { + RadioGroup( + options = options.map { (id, text) -> RadioOption(RadioOptionId(id), text) }, + selectedOption = selectedId?.let(::RadioOptionId), + onRadioOptionSelected = { id -> onSelected(id.id) }, + style = RadioGroupStyle.Labeled.HorizontalFlow(label = label), + enabled = isEnabled, + modifier = Modifier.fillMaxWidth(), ) + if (errorText != null) { + HedvigText( + text = errorText, + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } } } +// TODO: Add "Yes" / "Ja" to Lokalise +// TODO: Add "No" / "Nej" to Lokalise +private fun yesNoOptions(): List> = listOf("true" to "Yes", "false" to "No") + private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { return if (this != null) { HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) @@ -353,28 +314,22 @@ private fun PreviewVacationHomeFormEmpty() { VacationHomeFormContent( street = "", zipCode = "", - multipleOwners = null, yearOfConstruction = "", livingSpace = "", - hasWaterConnected = null, numberOfBathrooms = 1, + multipleOwners = null, + hasWaterConnected = null, isSubleted = null, - streetError = null, - zipCodeError = null, - multipleOwnersError = null, - yearOfConstructionError = null, - livingSpaceError = null, - hasWaterConnectedError = null, - isSubletedError = null, + errors = VacationHomeFormState(), isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, - onMultipleOwnersChanged = {}, onYearOfConstructionChanged = {}, onLivingSpaceChanged = {}, - onHasWaterConnectedChanged = {}, onNumberOfBathroomsChanged = {}, - onIsSubletedChanged = {}, + onMultipleOwnersSelected = {}, + onHasWaterConnectedSelected = {}, + onIsSubletedSelected = {}, onSubmit = {}, ) } @@ -389,28 +344,22 @@ private fun PreviewVacationHomeFormFilled() { VacationHomeFormContent( street = "Storgatan 1", zipCode = "12345", - multipleOwners = false, yearOfConstruction = "1985", livingSpace = "60", + numberOfBathrooms = 2, + multipleOwners = false, hasWaterConnected = true, - numberOfBathrooms = 1, isSubleted = false, - streetError = null, - zipCodeError = null, - multipleOwnersError = null, - yearOfConstructionError = null, - livingSpaceError = null, - hasWaterConnectedError = null, - isSubletedError = null, + errors = VacationHomeFormState(), isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, - onMultipleOwnersChanged = {}, onYearOfConstructionChanged = {}, onLivingSpaceChanged = {}, - onHasWaterConnectedChanged = {}, onNumberOfBathroomsChanged = {}, - onIsSubletedChanged = {}, + onMultipleOwnersSelected = {}, + onHasWaterConnectedSelected = {}, + onIsSubletedSelected = {}, onSubmit = {}, ) } @@ -425,28 +374,30 @@ private fun PreviewVacationHomeFormErrors() { VacationHomeFormContent( street = "", zipCode = "12", - multipleOwners = null, yearOfConstruction = "1500", livingSpace = "", - hasWaterConnected = null, numberOfBathrooms = 1, + multipleOwners = null, + hasWaterConnected = null, isSubleted = null, - streetError = "Ange en adress", - zipCodeError = "Ange ett giltigt postnummer (5 siffror)", - multipleOwnersError = "Välj ett alternativ", - yearOfConstructionError = "Ange ett giltigt byggår", - livingSpaceError = "Ange boyta", - hasWaterConnectedError = "Välj ett alternativ", - isSubletedError = "Välj ett alternativ", + errors = VacationHomeFormState( + streetError = "Enter an address", + zipCodeError = "Enter a valid zip code (5 digits)", + multipleOwnersError = "Choose an option", + yearOfConstructionError = "Enter a valid year of construction", + livingSpaceError = "Enter living space", + hasWaterConnectedError = "Choose an option", + isSubletedError = "Choose an option", + ), isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, - onMultipleOwnersChanged = {}, onYearOfConstructionChanged = {}, onLivingSpaceChanged = {}, - onHasWaterConnectedChanged = {}, onNumberOfBathroomsChanged = {}, - onIsSubletedChanged = {}, + onMultipleOwnersSelected = {}, + onHasWaterConnectedSelected = {}, + onIsSubletedSelected = {}, onSubmit = {}, ) } diff --git a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt index b3eda7c478..7e483937a7 100644 --- a/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt +++ b/app/feature/feature-purchase-house/src/main/kotlin/com/hedvig/android/feature/purchase/house/ui/vacationhome/VacationHomeFormViewModel.kt @@ -30,15 +30,18 @@ internal class VacationHomeFormViewModel( ) internal sealed interface VacationHomeFormEvent { + data class UpdateMultipleOwners(val value: Boolean) : VacationHomeFormEvent + + data class UpdateHasWaterConnected(val value: Boolean) : VacationHomeFormEvent + + data class UpdateIsSubleted(val value: Boolean) : VacationHomeFormEvent + data class SubmitForm( val street: String, val zipCode: String, - val multipleOwners: Boolean?, val yearOfConstruction: String, val livingSpace: String, - val hasWaterConnected: Boolean?, val numberOfBathrooms: Int, - val isSubleted: Boolean?, ) : VacationHomeFormEvent data object ClearNavigation : VacationHomeFormEvent @@ -49,6 +52,9 @@ internal sealed interface VacationHomeFormEvent { } internal data class VacationHomeFormState( + val multipleOwners: Boolean? = null, + val hasWaterConnected: Boolean? = null, + val isSubleted: Boolean? = null, val streetError: String? = null, val zipCodeError: String? = null, val multipleOwnersError: String? = null, @@ -85,36 +91,30 @@ private class VacationHomeFormPresenter( CollectEvents { event -> when (event) { + is VacationHomeFormEvent.UpdateMultipleOwners -> { + currentState = currentState.copy(multipleOwners = event.value, multipleOwnersError = null) + } + + is VacationHomeFormEvent.UpdateHasWaterConnected -> { + currentState = currentState.copy(hasWaterConnected = event.value, hasWaterConnectedError = null) + } + + is VacationHomeFormEvent.UpdateIsSubleted -> { + currentState = currentState.copy(isSubleted = event.value, isSubletedError = null) + } + is VacationHomeFormEvent.SubmitForm -> { - val errors = validate( - street = event.street, - zipCode = event.zipCode, - multipleOwners = event.multipleOwners, - yearOfConstruction = event.yearOfConstruction, - livingSpace = event.livingSpace, - hasWaterConnected = event.hasWaterConnected, - isSubleted = event.isSubleted, + val errors = validate(event, currentState) + currentState = currentState.copy( + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + multipleOwnersError = errors.multipleOwnersError, + yearOfConstructionError = errors.yearOfConstructionError, + livingSpaceError = errors.livingSpaceError, + hasWaterConnectedError = errors.hasWaterConnectedError, + isSubletedError = errors.isSubletedError, ) - if (errors.hasErrors()) { - currentState = currentState.copy( - streetError = errors.streetError, - zipCodeError = errors.zipCodeError, - multipleOwnersError = errors.multipleOwnersError, - yearOfConstructionError = errors.yearOfConstructionError, - livingSpaceError = errors.livingSpaceError, - hasWaterConnectedError = errors.hasWaterConnectedError, - isSubletedError = errors.isSubletedError, - ) - } else { - currentState = currentState.copy( - streetError = null, - zipCodeError = null, - multipleOwnersError = null, - yearOfConstructionError = null, - livingSpaceError = null, - hasWaterConnectedError = null, - isSubletedError = null, - ) + if (!errors.hasErrors()) { pendingSubmit = event submitIteration++ } @@ -155,11 +155,11 @@ private class VacationHomeFormPresenter( LaunchedEffect(submitIteration) { val submit = pendingSubmit ?: return@LaunchedEffect val session = sessionAndIntent ?: return@LaunchedEffect - val multipleOwners = submit.multipleOwners ?: return@LaunchedEffect + val multipleOwners = currentState.multipleOwners ?: return@LaunchedEffect val yearOfConstruction = submit.yearOfConstruction.toIntOrNull() ?: return@LaunchedEffect val livingSpace = submit.livingSpace.toIntOrNull() ?: return@LaunchedEffect - val hasWaterConnected = submit.hasWaterConnected ?: return@LaunchedEffect - val isSubleted = submit.isSubleted ?: return@LaunchedEffect + val hasWaterConnected = currentState.hasWaterConnected ?: return@LaunchedEffect + val isSubleted = currentState.isSubleted ?: return@LaunchedEffect pendingSubmit = null currentState = currentState.copy(isSubmitting = true, submitError = null) submitVacationHomeFormAndGetOffersUseCase.invoke( @@ -215,35 +215,41 @@ private data class ValidationErrors( isSubletedError != null } -private fun validate( - street: String, - zipCode: String, - multipleOwners: Boolean?, - yearOfConstruction: String, - livingSpace: String, - hasWaterConnected: Boolean?, - isSubleted: Boolean?, -): ValidationErrors { +private fun validate(event: VacationHomeFormEvent.SubmitForm, state: VacationHomeFormState): ValidationErrors { val currentYear = LocalDate.now().year return ValidationErrors( - streetError = if (street.isBlank()) "Ange en adress" else null, + // TODO: Add "Enter an address" / "Ange en adress" to Lokalise + streetError = if (event.street.isBlank()) "Enter an address" else null, zipCodeError = when { - zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" - !zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + // TODO: Add "Enter a valid zip code (5 digits)" / "Ange ett giltigt postnummer (5 siffror)" to Lokalise + event.zipCode.length != 5 -> "Enter a valid zip code (5 digits)" + + // TODO: Add "Zip code must contain only digits" / "Postnumret får bara innehålla siffror" to Lokalise + !event.zipCode.all { it.isDigit() } -> "Zip code must contain only digits" + else -> null }, - multipleOwnersError = if (multipleOwners == null) "Välj ett alternativ" else null, - yearOfConstructionError = when (val year = yearOfConstruction.toIntOrNull()) { - null -> "Ange byggår" - !in 1700..currentYear -> "Ange ett giltigt byggår" + // TODO: Add "Choose an option" / "Välj ett alternativ" to Lokalise + multipleOwnersError = if (state.multipleOwners == null) "Choose an option" else null, + yearOfConstructionError = when (val year = event.yearOfConstruction.toIntOrNull()) { + // TODO: Add "Enter year of construction" / "Ange byggår" to Lokalise + null -> "Enter year of construction" + + // TODO: Add "Enter a valid year of construction" / "Ange ett giltigt byggår" to Lokalise + !in 1700..currentYear -> "Enter a valid year of construction" + else -> null }, - livingSpaceError = when (val space = livingSpace.toIntOrNull()) { - null -> "Ange boyta" - !in 1..Int.MAX_VALUE -> "Ange en giltig boyta" + livingSpaceError = when (val space = event.livingSpace.toIntOrNull()) { + // TODO: Add "Enter living space" / "Ange boyta" to Lokalise + null -> "Enter living space" + + // TODO: Add "Enter a valid living space" / "Ange en giltig boyta" to Lokalise + !in 1..Int.MAX_VALUE -> "Enter a valid living space" + else -> null }, - hasWaterConnectedError = if (hasWaterConnected == null) "Välj ett alternativ" else null, - isSubletedError = if (isSubleted == null) "Välj ett alternativ" else null, + hasWaterConnectedError = if (state.hasWaterConnected == null) "Choose an option" else null, + isSubletedError = if (state.isSubleted == null) "Choose an option" else null, ) }