From b272803eae01aa818a0206eccfd73b9db4d6ff1e Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:13:10 +0200 Subject: [PATCH 01/22] chore: add design spec for in-app apartment purchase flow Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-03-31-in-app-apartment-purchase-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md diff --git a/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md b/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md new file mode 100644 index 0000000000..bc6a469592 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md @@ -0,0 +1,241 @@ +# In-App Apartment Purchase Flow + +## Overview + +Replace the current link-out to hedvig.com for apartment insurance cross-sells with a fully native Compose purchase flow. Covers SE_APARTMENT_RENT and SE_APARTMENT_BRF products. + +## Scope + +### In scope (v1) +- Manual form entry: street, zipCode, livingSpace, numberCoInsured +- Tier selection: BAS / STANDARD / MAX (racoon-style cards) +- Summary with price confirmation +- BankID signing via existing app launch pattern +- Success / failure screens +- Entry from cross-sell on insurance tab + +### Out of scope (v1) +- Address pre-fill from SSN (SPAR lookup) +- Coverage comparison screen +- Cross-sell / upsell within purchase flow +- Multi-product cart ("add another insurance") +- Payment setup (assumed already done) +- Other product types (car, pet, accident, house) +- Generic/dynamic form engine + +## Module Structure + +New module: `app/feature/feature-purchase-apartment/` + +``` +feature-purchase-apartment/ +├── build.gradle.kts +└── src/main/ + ├── kotlin/com/hedvig/android/feature/purchase/apartment/ + │ ├── navigation/ + │ │ ├── ApartmentPurchaseDestination.kt + │ │ └── ApartmentPurchaseNavGraph.kt + │ ├── data/ + │ │ ├── CreateShopSessionUseCase.kt + │ │ ├── UpdatePriceIntentUseCase.kt + │ │ ├── ConfirmPriceIntentUseCase.kt + │ │ ├── AddToCartAndSignUseCase.kt + │ │ ├── PollSigningStatusUseCase.kt + │ │ └── PurchaseModels.kt + │ ├── ui/ + │ │ ├── form/ + │ │ │ ├── ApartmentFormViewModel.kt + │ │ │ └── ApartmentFormDestination.kt + │ │ ├── offer/ + │ │ │ ├── SelectTierViewModel.kt + │ │ │ └── SelectTierDestination.kt + │ │ ├── summary/ + │ │ │ ├── PurchaseSummaryViewModel.kt + │ │ │ └── PurchaseSummaryDestination.kt + │ │ ├── sign/ + │ │ │ ├── SigningViewModel.kt + │ │ │ └── SigningDestination.kt + │ │ ├── success/ + │ │ │ └── PurchaseSuccessDestination.kt + │ │ └── failure/ + │ │ └── PurchaseFailureDestination.kt + │ └── di/ + │ └── ApartmentPurchaseModule.kt + └── graphql/ + ├── ShopSessionCreateMutation.graphql + ├── PriceIntentCreateMutation.graphql + ├── PriceIntentDataUpdateMutation.graphql + ├── PriceIntentConfirmMutation.graphql + ├── ShopSessionCartEntriesAddMutation.graphql + ├── ShopSessionStartSignMutation.graphql + └── ShopSessionSigningQuery.graphql +``` + +## Architecture + +### Navigation Graph + +``` +CrossSell click (insurance tab) + └─→ ApartmentPurchaseGraphDestination(productType: RENT|BRF) + ├── ApartmentForm (start destination) + ├── SelectTier(offers) + ├── Summary(selectedOffer) + └── Signing(shopSessionId) + +PurchaseSuccess (outside graph, terminal) +PurchaseFailure (inside graph, retryable) +``` + +Following the termination flow pattern: inner `navgraph` for in-progress steps, `PurchaseSuccess` outside so back-press exits the feature. On success, the entire purchase graph is popped. + +### Destinations + +| Destination | Purpose | ViewModel | API calls | +|-------------|---------|-----------|-----------| +| ApartmentForm | Collect address + living details | ApartmentFormViewModel | shopSessionCreate, priceIntentCreate, priceIntentDataUpdate, priceIntentConfirm | +| SelectTier | Show BAS/STANDARD/MAX offers | SelectTierViewModel | None (offers from nav args) | +| Summary | Review selection + confirm | PurchaseSummaryViewModel | shopSessionCartEntriesAdd, shopSessionStartSign | +| Signing | BankID launch + poll | SigningViewModel | shopSessionSigning (poll) | +| PurchaseSuccess | Terminal success | None (stateless) | CrossSellAfterFlowRepository signal | +| PurchaseFailure | Error with retry | None (stateless) | None | + +### State Threading + +Navigation arguments (serializable data classes), not DataStore. The flow is short enough (4 active steps) that nav args suffice. If the app is killed, the user restarts the flow. + +Key serializable types passed through navigation: +- `productType: ApartmentProductType` (RENT or BRF) — passed into the graph +- `shopSessionId: String` — created at form submit, threaded through all subsequent steps +- `offers: List` — generated by priceIntentConfirm, passed to SelectTier +- `selectedOffer: TierOffer` — chosen by user, passed to Summary +- `signingId: String` — returned by shopSessionStartSign, passed to Signing + +### API Flow + +``` +Form submit: + 1. shopSessionCreate(countryCode: SE) → shopSessionId + 2. priceIntentCreate(shopSessionId, productName) → priceIntentId + 3. priceIntentDataUpdate(priceIntentId, {street, zipCode, livingSpace, numberCoInsured}) + 4. priceIntentConfirm(priceIntentId) → ProductOffer[] with tiers + +Tier selection: + (no API call — pure UI) + +Summary confirm: + 5. shopSessionCartEntriesAdd(shopSessionId, [selectedOfferId]) + 6. shopSessionStartSign(shopSessionId) → autoStartToken + signingId + +Signing: + 7. Launch BankID app via autoStartToken URI + 8. Poll shopSessionSigning(signingId) until SIGNED or FAILED +``` + +### Presenter Pattern + +Standard MoleculeViewModel + MoleculePresenter per screen: + +- **ApartmentFormPresenter** — validates inputs using `ValidatedInput` pattern from moving flow. On submit: fires API calls 1-4 sequentially, shows loading state, on success navigates with offers. Validation rules: street (non-blank), zipCode (5 digits), livingSpace (non-null, positive int), numberCoInsured (0+). +- **SelectTierPresenter** — receives offers via nav args. Manages selected tier. Computes price display (net/gross with any bundle discount). Navigation side-effect pattern for proceeding to summary. +- **PurchaseSummaryPresenter** — receives selected offer via nav args. Dual LaunchedEffect pattern (like addon summary): one for loading display data, one for submit. On confirm: adds to cart + starts signing. Navigates to Signing on success. +- **SigningPresenter** — launches BankID via `BankIdState` pattern (autoStartToken → URI → Intent). Polls `shopSessionSigning` with interval. Handles: SIGNED → success, FAILED → failure, timeout → failure. + +### Error Handling + +Three layers, matching existing codebase conventions: +- **Network errors**: `safeExecute()` returns `Either`, mapped to `ErrorMessage` +- **Backend business errors**: `UserError` union member on mutations, surfaced as specific error messages +- **Presenter-level**: Loading/Content/Error sealed states. Submit failures navigate to PurchaseFailure destination. Form validation errors shown inline. + +## UI Design + +### Form Screen +- Standard `HedvigScaffold` with top bar + back navigation +- `HedvigTextField` for street, zipCode, livingSpace +- `HedvigStepper` (or number input) for numberCoInsured +- Submit button at bottom, disabled until all fields valid +- Loading overlay during API calls + +### Tier Selection Screen (racoon-inspired) +- Pillow image with selected tier badge at top +- Price display with bundle discount (strikethrough + discounted price) +- Three tier cards stacked vertically: + - **Selected card (expanded):** dark background, tier name + price badge, description, feature highlights with checkmarks, "Se vad som ingår" button + - **Unselected cards (collapsed):** light background, tier name + price, "Välj {tier}" button +- Tapping an unselected card selects it (expands it, collapses others) +- Continue button at bottom + +### Summary Screen +- Product card: pillow image, product name, address, selected tier +- Price breakdown: monthly cost, any bundle discount +- Confirm/sign button +- Back navigation to change selection + +### Signing Screen +- Loading indicator with "Öppnar BankID..." status text +- BankID app launches automatically +- Poll status with visual feedback +- Timeout handling with retry option + +### Success Screen +- Success illustration/animation +- "Din försäkring är klar" message +- Insurance start date +- Close button (pops entire flow) +- Triggers cross-sell after-flow banner + +## Entry Point Integration + +Modify the cross-sell click handler in the insurance tab: + +1. Add product type info to `CrossSell` data model (derive from backend data or add to GraphQL query) +2. In `InsuranceGraph.kt`, check if product is SE_APARTMENT_RENT or SE_APARTMENT_BRF: + - Yes → navigate to `ApartmentPurchaseGraphDestination(productType)` + - No → continue with `openUrl(storeUrl)` (existing behavior) +3. Register `apartmentPurchaseNavGraph` in the app's main NavHost +4. Add `apartmentPurchaseModule` to the application-level Koin module + +## Dependencies + +### Module dependencies +- `projects.coreCommonPublic` — common utilities +- `projects.navigationCompose` — navigation extensions +- `projects.designSystemHedvig` — UI components +- `projects.apolloOctopusPublic` — GraphQL schema types +- `projects.dataCrossSellAfterFlow` — cross-sell completion signal +- `projects.coreResources` — string resources + +### No dependency on +- Other feature modules (enforced by build plugin) +- Login/auth modules (BankID pattern is replicated, not imported) + +## Build Configuration + +```kotlin +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + compose() + apollo("octopus") + serialization() +} + +dependencies { + implementation(projects.coreCommonPublic) + implementation(projects.navigationCompose) + implementation(projects.designSystemHedvig) + implementation(projects.apolloOctopusPublic) + implementation(projects.dataCrossSellAfterFlow) + implementation(projects.coreResources) +} +``` + +## Future Extensions + +When adding more products, each would get its own feature module (e.g., `feature-purchase-car/`) with product-specific form fields but sharing the same ShopSession API pattern. Shared components (tier cards, signing screen, success screen) could be extracted to a `ui-purchase-common` module if duplication warrants it. + +A generic form engine (like racoon's PriceTemplate system) could be built later if the number of products grows, replacing product-specific form screens with a dynamic renderer. From 7445720d5fd892a2a740785ebf6f4856ec418af2 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:24:04 +0200 Subject: [PATCH 02/22] chore: add implementation plan for in-app apartment purchase flow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-31-in-app-apartment-purchase.md | 2347 +++++++++++++++++ 1 file changed, 2347 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md diff --git a/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md b/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md new file mode 100644 index 0000000000..64f718ee06 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-in-app-apartment-purchase.md @@ -0,0 +1,2347 @@ +# In-App Apartment Purchase Flow — 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:** Build a native Compose purchase flow for SE_APARTMENT_RENT and SE_APARTMENT_BRF, replacing the current link-out to hedvig.com for apartment cross-sells. + +**Architecture:** New feature module `feature-purchase-apartment` using the ShopSession + PriceIntent GraphQL API (same as racoon web). Follows MoleculeViewModel + MoleculePresenter pattern. State threaded via serializable navigation arguments. Entry point via cross-sell click on insurance tab. + +**Tech Stack:** Jetpack Compose, Apollo GraphQL, Molecule, Koin, Arrow Either, kotlinx.serialization, Navigation Compose (type-safe) + +**Spec:** `docs/superpowers/specs/2026-03-31-in-app-apartment-purchase-design.md` + +--- + +## File Map + +### New files (feature module) + +| File | Responsibility | +|------|---------------| +| `feature-purchase-apartment/build.gradle.kts` | Module build config | +| `.../navigation/ApartmentPurchaseDestination.kt` | All destination types + serializable parameter classes | +| `.../navigation/ApartmentPurchaseNavGraph.kt` | Navigation graph wiring | +| `.../data/PurchaseApartmentModels.kt` | Domain models: `TierOffer`, `ApartmentFormData` | +| `.../data/CreateSessionAndPriceIntentUseCase.kt` | Creates ShopSession + PriceIntent | +| `.../data/SubmitFormAndGetOffersUseCase.kt` | Calls priceIntentDataUpdate + priceIntentConfirm | +| `.../data/AddToCartAndStartSignUseCase.kt` | Adds to cart + starts signing | +| `.../data/PollSigningStatusUseCase.kt` | Polls shopSessionSigning | +| `.../ui/form/ApartmentFormViewModel.kt` | Form presenter with validation | +| `.../ui/form/ApartmentFormDestination.kt` | Form screen composable | +| `.../ui/offer/SelectTierViewModel.kt` | Tier selection presenter | +| `.../ui/offer/SelectTierDestination.kt` | Tier selection screen composable | +| `.../ui/summary/PurchaseSummaryViewModel.kt` | Summary + submit presenter | +| `.../ui/summary/PurchaseSummaryDestination.kt` | Summary screen composable | +| `.../ui/sign/SigningViewModel.kt` | BankID signing presenter | +| `.../ui/sign/SigningDestination.kt` | Signing screen composable | +| `.../ui/success/PurchaseSuccessDestination.kt` | Success screen (stateless) | +| `.../ui/failure/PurchaseFailureDestination.kt` | Failure screen (stateless) | +| `.../di/ApartmentPurchaseModule.kt` | Koin DI module | +| `src/main/graphql/ShopSessionCreateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentCreateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentDataUpdateMutation.graphql` | GraphQL | +| `src/main/graphql/PriceIntentConfirmMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionStartSignMutation.graphql` | GraphQL | +| `src/main/graphql/ShopSessionSigningQuery.graphql` | GraphQL | +| `src/main/graphql/ProductOfferFragment.graphql` | Shared fragment | +| `src/main/graphql/MoneyFragment.graphql` | Shared fragment | + +### Modified files (integration) + +| File | Change | +|------|--------| +| `app/app/src/main/kotlin/.../navigation/HedvigNavHost.kt` | Register `apartmentPurchaseNavGraph`, add `onNavigateToApartmentPurchase` to `insuranceGraph` call | +| `app/app/src/main/kotlin/.../di/ApplicationModule.kt` | Add `apartmentPurchaseModule` to includes | +| `app/feature/feature-insurances/src/main/kotlin/.../navigation/InsuranceGraph.kt` | Add `onNavigateToApartmentPurchase` parameter, wire cross-sell click routing | +| `app/feature/feature-insurances/src/main/kotlin/.../ui/InsurancePresenter.kt` | Pass product type info through cross-sell click | +| `app/data/data-cross-sell-after-flow/.../CrossSellAfterFlowRepository.kt` | Add `Purchase` to `CrossSellInfoType` | + +--- + +## Task 1: Module scaffolding and GraphQL operations + +**Files:** +- Create: `app/feature/feature-purchase-apartment/build.gradle.kts` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/MoneyFragment.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql` +- Create: `app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt` + +- [ ] **Step 1: Create the module directory and build.gradle.kts** + +```bash +mkdir -p app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment +mkdir -p app/feature/feature-purchase-apartment/src/main/graphql +``` + +`app/feature/feature-purchase-apartment/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.languageCore) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} +``` + +- [ ] **Step 2: Create the GraphQL fragment files** + +`src/main/graphql/MoneyFragment.graphql`: +```graphql +fragment MoneyFragment on Money { + amount + currencyCode +} +``` + +`src/main/graphql/ProductOfferFragment.graphql`: +```graphql +fragment ApartmentProductOfferFragment 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 3: Create the mutation and query GraphQL files** + +`src/main/graphql/ShopSessionCreateMutation.graphql`: +```graphql +mutation ApartmentShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} +``` + +`src/main/graphql/PriceIntentCreateMutation.graphql`: +```graphql +mutation ApartmentPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} +``` + +`src/main/graphql/PriceIntentDataUpdateMutation.graphql`: +```graphql +mutation ApartmentPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} +``` + +`src/main/graphql/PriceIntentConfirmMutation.graphql`: +```graphql +mutation ApartmentPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...ApartmentProductOfferFragment + } + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionCartEntriesAddMutation.graphql`: +```graphql +mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionStartSignMutation.graphql`: +```graphql +mutation ApartmentStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} +``` + +`src/main/graphql/ShopSessionSigningQuery.graphql`: +```graphql +query ApartmentShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} +``` + +- [ ] **Step 4: Create the destination definitions** + +`app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination + + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : ApartmentPurchaseDestination, Destination + + @Serializable + data object Failure : ApartmentPurchaseDestination, Destination +} + +@Serializable +internal data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +internal data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +internal data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +internal data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) +``` + +- [ ] **Step 5: Verify the module builds with Apollo codegen** + +```bash +./gradlew :feature-purchase-apartment:generateApolloSources +``` + +Expected: BUILD SUCCESSFUL. Apollo generates Kotlin types for all 7 GraphQL operations and 2 fragments. + +- [ ] **Step 6: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: scaffold feature-purchase-apartment module with GraphQL operations" +``` + +--- + +## Task 2: Domain models and use cases + +**Files:** +- Create: `.../data/PurchaseApartmentModels.kt` +- Create: `.../data/CreateSessionAndPriceIntentUseCase.kt` +- Create: `.../data/SubmitFormAndGetOffersUseCase.kt` +- Create: `.../data/AddToCartAndStartSignUseCase.kt` +- Create: `.../data/PollSigningStatusUseCase.kt` +- Test: `src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt` +- Test: `src/test/kotlin/data/SubmitFormAndGetOffersUseCaseTest.kt` +- Test: `src/test/kotlin/data/AddToCartAndStartSignUseCaseTest.kt` + +- [ ] **Step 1: Create domain models** + +`.../data/PurchaseApartmentModels.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class ApartmentTierOffer( + 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, +) + +internal data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +internal enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} +``` + +- [ ] **Step 2: Create CreateSessionAndPriceIntentUseCase** + +`.../data/CreateSessionAndPriceIntentUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.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.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(ApartmentShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(ApartmentPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} +``` + +- [ ] **Step 3: Create SubmitFormAndGetOffersUseCase** + +`.../data/SubmitFormAndGetOffersUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.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.ApartmentPriceIntentConfirmMutation +import octopus.ApartmentPriceIntentDataUpdateMutation +import octopus.fragment.ApartmentProductOfferFragment + +internal interface SubmitFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either +} + +internal class SubmitFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either { + return either { + val formData = buildMap { + put("street", street) + put("zipCode", zipCode) + put("livingSpace", livingSpace) + put("numberCoInsured", numberCoInsured) + } + + val updateResult = apolloClient + .mutation(ApartmentPriceIntentDataUpdateMutation(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(ApartmentPriceIntentConfirmMutation(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()) + } + + ApartmentOffers( + productDisplayName = offers.first().apartmentProductOfferFragment.variant.displayName, + offers = offers.map { it.apartmentProductOfferFragment.toTierOffer() }, + ) + } + } +} + +internal fun ApartmentProductOfferFragment.toTierOffer(): ApartmentTierOffer { + val cost = this.cost + return ApartmentTierOffer( + offerId = this.id, + tierDisplayName = this.variant.displayNameTier ?: this.variant.displayName, + tierDescription = this.variant.tierDescription ?: "", + grossPrice = UiMoney(cost.gross.moneyFragment.amount, cost.gross.moneyFragment.currencyCode), + netPrice = UiMoney(cost.net.moneyFragment.amount, cost.net.moneyFragment.currencyCode), + usps = this.usps, + exposureDisplayName = this.exposure.displayNameShort, + deductibleDisplayName = this.deductible?.displayName, + hasDiscount = cost.net.moneyFragment.amount < cost.gross.moneyFragment.amount, + ) +} +``` + +- [ ] **Step 4: Create AddToCartAndStartSignUseCase** + +`.../data/AddToCartAndStartSignUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.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.ApartmentCartEntriesAddMutation +import octopus.ApartmentStartSignMutation + +internal interface AddToCartAndStartSignUseCase { + suspend fun invoke(shopSessionId: String, offerId: String): Either +} + +internal class AddToCartAndStartSignUseCaseImpl( + private val apolloClient: ApolloClient, +) : AddToCartAndStartSignUseCase { + override suspend fun invoke(shopSessionId: String, offerId: String): Either { + return either { + val cartResult = apolloClient + .mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to add to cart: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCartEntriesAdd }, + ) + + if (cartResult?.userError != null) { + raise(ErrorMessage(cartResult.userError?.message)) + } + + val signResult = apolloClient + .mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to start signing: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionStartSign }, + ) + + if (signResult.userError != null) { + raise(ErrorMessage(signResult.userError?.message)) + } + + val signing = signResult.signing ?: run { + logcat(LogPriority.ERROR) { "No signing session returned" } + raise(ErrorMessage()) + } + + val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run { + logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" } + raise(ErrorMessage()) + } + + SigningStart( + signingId = signing.id, + autoStartToken = autoStartToken, + ) + } + } +} +``` + +- [ ] **Step 5: Create PollSigningStatusUseCase** + +`.../data/PollSigningStatusUseCase.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.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.ApartmentShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +internal interface PollSigningStatusUseCase { + suspend fun invoke(signingId: String): Either +} + +internal class PollSigningStatusUseCaseImpl( + private val apolloClient: ApolloClient, +) : PollSigningStatusUseCase { + override suspend fun invoke(signingId: String): Either { + return either { + apolloClient + .query(ApartmentShopSessionSigningQuery(signingId = signingId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + when (result.shopSessionSigning.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__ -> SigningStatus.PENDING + } + }, + ) + } + } +} +``` + +- [ ] **Step 6: Write tests for CreateSessionAndPriceIntentUseCase** + +`src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt`: +```kotlin +package data + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.prop +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.annotations.ApolloExperimental +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.testing.registerTestResponse +import com.hedvig.android.apollo.octopus.test.OctopusFakeResolver +import com.hedvig.android.apollo.test.TestApolloClientRule +import com.hedvig.android.apollo.test.TestNetworkTransportType +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.test.isLeft +import com.hedvig.android.core.common.test.isRight +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.logger.TestLogcatLoggingRule +import kotlinx.coroutines.test.runTest +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode +import org.junit.Rule +import org.junit.Test + +class CreateSessionAndPriceIntentUseCaseTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + @get:Rule + val testApolloClientRule = TestApolloClientRule(TestNetworkTransportType.MAP) + + @OptIn(ApolloExperimental::class) + @Test + fun `successful session and price intent creation returns both ids`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = ApartmentShopSessionCreateMutation.Data(OctopusFakeResolver) { + shopSessionCreate = buildShopSession { + id = "session-123" + } + }, + ) + registerTestResponse( + operation = ApartmentPriceIntentCreateMutation(shopSessionId = "session-123", productName = "SE_APARTMENT_RENT"), + data = ApartmentPriceIntentCreateMutation.Data(OctopusFakeResolver) { + priceIntentCreate = buildPriceIntent { + id = "intent-456" + } + }, + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isRight().isEqualTo(SessionAndIntent("session-123", "intent-456")) + } + + @OptIn(ApolloExperimental::class) + @Test + fun `network error on session creation returns ErrorMessage`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = null, + errors = listOf(Error.Builder(message = "Network error").build()), + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isLeft().prop(ErrorMessage::message).isNull() + } +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +./gradlew :feature-purchase-apartment:test +``` + +Expected: All tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add domain models and use cases for apartment purchase" +``` + +--- + +## Task 3: Form screen (presenter + UI) + +**Files:** +- Create: `.../ui/form/ApartmentFormViewModel.kt` +- Create: `.../ui/form/ApartmentFormDestination.kt` + +- [ ] **Step 1: Create ApartmentFormViewModel with presenter** + +`.../ui/form/ApartmentFormViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.form + +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.core.common.ErrorMessage +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class ApartmentFormViewModel( + productName: String, + createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = ApartmentFormState.Idle(), + presenter = ApartmentFormPresenter(productName, createSessionAndPriceIntentUseCase, submitFormAndGetOffersUseCase), + ) + +internal class ApartmentFormPresenter( + private val productName: String, + private val createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + private val submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: ApartmentFormState): ApartmentFormState { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var numberCoInsured by remember { mutableIntStateOf(0) } + + var streetError by remember { mutableStateOf(null) } + var zipCodeError by remember { mutableStateOf(null) } + var livingSpaceError by remember { mutableStateOf(null) } + + var submitIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(false) } + var submitError by remember { mutableStateOf(null) } + var offersToNavigate by remember { mutableStateOf(null) } + + var sessionAndIntent by remember { mutableStateOf(null) } + var sessionCreateIteration by remember { mutableIntStateOf(0) } + + CollectEvents { event -> + when (event) { + is ApartmentFormEvent.UpdateStreet -> { + street = event.value + streetError = null + } + is ApartmentFormEvent.UpdateZipCode -> { + zipCode = event.value + zipCodeError = null + } + is ApartmentFormEvent.UpdateLivingSpace -> { + livingSpace = event.value + livingSpaceError = null + } + is ApartmentFormEvent.UpdateNumberCoInsured -> { + numberCoInsured = event.value + } + ApartmentFormEvent.Submit -> { + val validStreet = street.isNotBlank() + val validZipCode = zipCode.length == 5 && zipCode.all { it.isDigit() } + val validLivingSpace = livingSpace.toIntOrNull()?.let { it > 0 } ?: false + + streetError = if (!validStreet) "Ange en adress" else null + zipCodeError = if (!validZipCode) "Ange ett giltigt postnummer (5 siffror)" else null + livingSpaceError = if (!validLivingSpace) "Ange boyta i kvadratmeter" else null + + if (validStreet && validZipCode && validLivingSpace) { + submitIteration++ + } + } + ApartmentFormEvent.ClearNavigation -> { + offersToNavigate = null + } + ApartmentFormEvent.Retry -> { + submitError = null + sessionCreateIteration++ + } + } + } + + LaunchedEffect(sessionCreateIteration) { + if (sessionAndIntent != null) return@LaunchedEffect + createSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { submitError = it }, + ifRight = { sessionAndIntent = it }, + ) + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + isSubmitting = true + submitError = null + + submitFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + street = street, + zipCode = zipCode, + livingSpace = livingSpace.toInt(), + numberCoInsured = numberCoInsured, + ).fold( + ifLeft = { + isSubmitting = false + submitError = it + }, + ifRight = { offers -> + isSubmitting = false + offersToNavigate = OffersNavigationData( + shopSessionId = session.shopSessionId, + offers = offers, + ) + }, + ) + } + + return ApartmentFormState( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + streetError = streetError, + zipCodeError = zipCodeError, + livingSpaceError = livingSpaceError, + isSubmitting = isSubmitting, + submitError = submitError, + offersToNavigate = offersToNavigate, + ) + } +} + +internal data class OffersNavigationData( + val shopSessionId: String, + val offers: ApartmentOffers, +) + +internal sealed interface ApartmentFormEvent { + data class UpdateStreet(val value: String) : ApartmentFormEvent + data class UpdateZipCode(val value: String) : ApartmentFormEvent + data class UpdateLivingSpace(val value: String) : ApartmentFormEvent + data class UpdateNumberCoInsured(val value: Int) : ApartmentFormEvent + data object Submit : ApartmentFormEvent + data object ClearNavigation : ApartmentFormEvent + data object Retry : ApartmentFormEvent +} + +internal data class ApartmentFormState( + val street: String = "", + val zipCode: String = "", + val livingSpace: String = "", + val numberCoInsured: Int = 0, + val streetError: String? = null, + val zipCodeError: String? = null, + val livingSpaceError: String? = null, + val isSubmitting: Boolean = false, + val submitError: ErrorMessage? = null, + val offersToNavigate: OffersNavigationData? = null, +) { + constructor() : this("", "", "", 0) +} +``` + +- [ ] **Step 2: Create ApartmentFormDestination composable** + +`.../ui/form/ApartmentFormDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +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.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults.ErrorState +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers + +@Composable +internal fun ApartmentFormDestination( + viewModel: ApartmentFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: ApartmentOffers) -> Unit, +) { + val state = viewModel.state + LaunchedEffect(state.offersToNavigate) { + val nav = state.offersToNavigate ?: return@LaunchedEffect + viewModel.emit(ApartmentFormEvent.ClearNavigation) + onOffersReceived(nav.shopSessionId, nav.offers) + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Hemförsäkring", + ) { + if (state.isSubmitting) { + HedvigFullScreenCenterAlignedProgress() + return@HedvigScaffold + } + + if (state.submitError != null) { + HedvigErrorSection( + onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + return@HedvigScaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.street, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateStreet(it)) }, + labelText = "Gatuadress", + errorState = state.streetError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.zipCode, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateZipCode(it)) }, + labelText = "Postnummer", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + errorState = state.zipCodeError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.livingSpace, + onValueChange = { viewModel.emit(ApartmentFormEvent.UpdateLivingSpace(it)) }, + labelText = "Boyta (kvm)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + errorState = state.livingSpaceError?.let { ErrorState.Error.WithMessage(it) } ?: ErrorState.NoError, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + + HedvigTextField( + value = state.numberCoInsured.toString(), + onValueChange = { newValue -> + newValue.toIntOrNull()?.let { viewModel.emit(ApartmentFormEvent.UpdateNumberCoInsured(it)) } + }, + labelText = "Antal medförsäkrade", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(32.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Beräkna pris", + onClick = { viewModel.emit(ApartmentFormEvent.Submit) }, + enabled = !state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add apartment purchase form screen with validation" +``` + +--- + +## Task 4: Tier selection screen + +**Files:** +- Create: `.../ui/offer/SelectTierViewModel.kt` +- Create: `.../ui/offer/SelectTierDestination.kt` + +- [ ] **Step 1: Create SelectTierViewModel** + +`.../ui/offer/SelectTierViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class SelectTierViewModel( + selectTierParameters: SelectTierParameters, +) : MoleculeViewModel( + initialState = SelectTierState( + offers = selectTierParameters.offers, + selectedOfferId = selectTierParameters.offers.firstOrNull { it.tierDisplayName.contains("Standard", ignoreCase = true) }?.offerId + ?: selectTierParameters.offers.firstOrNull()?.offerId, + shopSessionId = selectTierParameters.shopSessionId, + productDisplayName = selectTierParameters.productDisplayName, + ), + presenter = SelectTierPresenter(selectTierParameters), + ) + +internal class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierState): SelectTierState { + var selectedOfferId by remember { mutableStateOf(lastState.selectedOfferId) } + var summaryToNavigate by remember { mutableStateOf(null) } + + CollectEvents { event -> + when (event) { + is SelectTierEvent.SelectOffer -> { + selectedOfferId = event.offerId + } + SelectTierEvent.Continue -> { + val selectedOffer = params.offers.first { it.offerId == selectedOfferId } + summaryToNavigate = SummaryParameters( + shopSessionId = params.shopSessionId, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } + } + } + + return SelectTierState( + offers = params.offers, + selectedOfferId = selectedOfferId, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +internal sealed interface SelectTierEvent { + data class SelectOffer(val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent +} + +internal data class SelectTierState( + val offers: List, + val selectedOfferId: String?, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters? = null, +) +``` + +- [ ] **Step 2: Create SelectTierDestination composable** + +`.../ui/offer/SelectTierDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData + +@Composable +internal fun SelectTierDestination( + viewModel: SelectTierViewModel, + navigateUp: () -> Unit, + onContinueToSummary: (SummaryParameters) -> Unit, +) { + val state = viewModel.state + + LaunchedEffect(state.summaryToNavigate) { + val params = state.summaryToNavigate ?: return@LaunchedEffect + viewModel.emit(SelectTierEvent.ClearNavigation) + onContinueToSummary(params) + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = state.productDisplayName, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigText( + text = "Anpassa din försäkring", + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = "Välj en försäkringsnivå som passar dig", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + + Spacer(Modifier.height(24.dp)) + + state.offers.forEach { offer -> + TierCard( + offer = offer, + isSelected = offer.offerId == state.selectedOfferId, + onSelect = { viewModel.emit(SelectTierEvent.SelectOffer(offer.offerId)) }, + ) + Spacer(Modifier.height(12.dp)) + } + + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Fortsätt", + onClick = { viewModel.emit(SelectTierEvent.Continue) }, + enabled = state.selectedOfferId != null, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun TierCard( + offer: TierOfferData, + isSelected: Boolean, + onSelect: () -> Unit, +) { + val backgroundColor = if (isSelected) { + HedvigTheme.colorScheme.surfacePrimary + } else { + HedvigTheme.colorScheme.surfaceSecondary + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .clickable(onClick = onSelect) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + val priceText = if (offer.hasDiscount) { + "${offer.netAmount.toInt()} kr/mån" + } else { + "${offer.grossAmount.toInt()} kr/mån" + } + HedvigText( + text = priceText, + style = HedvigTheme.typography.labelLarge, + ) + } + + Spacer(Modifier.height(4.dp)) + HedvigText( + text = offer.tierDescription, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + + AnimatedVisibility(visible = isSelected && offer.usps.isNotEmpty()) { + Column(modifier = Modifier.padding(top = 12.dp)) { + offer.usps.forEach { usp -> + HedvigText( + text = "✓ $usp", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + if (isSelected) { + HedvigButton( + text = "Se vad som ingår", + onClick = { /* v1: no coverage comparison */ }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + HedvigButton( + text = "Välj ${offer.tierDisplayName}", + onClick = onSelect, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add tier selection screen for apartment purchase" +``` + +--- + +## Task 5: Summary and signing screens + +**Files:** +- Create: `.../ui/summary/PurchaseSummaryViewModel.kt` +- Create: `.../ui/summary/PurchaseSummaryDestination.kt` +- Create: `.../ui/sign/SigningViewModel.kt` +- Create: `.../ui/sign/SigningDestination.kt` +- Create: `.../ui/success/PurchaseSuccessDestination.kt` +- Create: `.../ui/failure/PurchaseFailureDestination.kt` + +- [ ] **Step 1: Create PurchaseSummaryViewModel** + +`.../ui/summary/PurchaseSummaryViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.summary + +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.core.common.ErrorMessage +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.SigningStart +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryState.Content(summaryParameters), + presenter = PurchaseSummaryPresenter(summaryParameters, addToCartAndStartSignUseCase), + ) + +internal class PurchaseSummaryPresenter( + private val params: SummaryParameters, + private val addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PurchaseSummaryState, + ): PurchaseSummaryState { + var submitIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(false) } + var signingToNavigate by remember { mutableStateOf(null) } + var navigateToFailure by remember { mutableStateOf(false) } + + CollectEvents { event -> + when (event) { + PurchaseSummaryEvent.Confirm -> submitIteration++ + PurchaseSummaryEvent.ClearNavigation -> { + signingToNavigate = null + navigateToFailure = false + } + } + } + + LaunchedEffect(submitIteration) { + if (submitIteration == 0) return@LaunchedEffect + isSubmitting = true + + addToCartAndStartSignUseCase.invoke( + shopSessionId = params.shopSessionId, + offerId = params.selectedOffer.offerId, + ).fold( + ifLeft = { + isSubmitting = false + navigateToFailure = true + }, + ifRight = { signingStart -> + isSubmitting = false + signingToNavigate = SigningParameters( + signingId = signingStart.signingId, + autoStartToken = signingStart.autoStartToken, + startDate = params.selectedOffer.exposureDisplayName, + ) + }, + ) + } + + return PurchaseSummaryState.Content( + params = params, + isSubmitting = isSubmitting, + signingToNavigate = signingToNavigate, + navigateToFailure = navigateToFailure, + ) + } +} + +internal sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent +} + +internal sealed interface PurchaseSummaryState { + data class Content( + val params: SummaryParameters, + val isSubmitting: Boolean = false, + val signingToNavigate: SigningParameters? = null, + val navigateToFailure: Boolean = false, + ) : PurchaseSummaryState +} +``` + +- [ ] **Step 2: Create PurchaseSummaryDestination** + +`.../ui/summary/PurchaseSummaryDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.summary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters + +@Composable +internal fun PurchaseSummaryDestination( + viewModel: PurchaseSummaryViewModel, + navigateUp: () -> Unit, + onNavigateToSigning: (SigningParameters) -> Unit, + onNavigateToFailure: () -> Unit, +) { + val state = viewModel.state as? PurchaseSummaryState.Content ?: return + + LaunchedEffect(state.signingToNavigate) { + val params = state.signingToNavigate ?: return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + onNavigateToSigning(params) + } + + LaunchedEffect(state.navigateToFailure) { + if (!state.navigateToFailure) return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + onNavigateToFailure() + } + + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Sammanfattning", + ) { + if (state.isSubmitting) { + HedvigFullScreenCenterAlignedProgress() + return@HedvigScaffold + } + + val offer = state.params.selectedOffer + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + + HedvigCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + HedvigText( + text = state.params.productDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + HedvigText( + text = offer.exposureDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + HedvigText(text = "Ditt pris", modifier = Modifier.weight(1f)) + if (offer.hasDiscount) { + HedvigText( + text = "${offer.netAmount.toInt()} kr/mån", + style = HedvigTheme.typography.bodyLarge, + ) + } else { + HedvigText( + text = "${offer.grossAmount.toInt()} kr/mån", + style = HedvigTheme.typography.bodyLarge, + ) + } + } + } + } + + Spacer(Modifier.height(32.dp)) + Spacer(Modifier.weight(1f)) + + HedvigButton( + text = "Signera med BankID", + onClick = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, + enabled = !state.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + } +} +``` + +- [ ] **Step 3: Create SigningViewModel** + +`.../ui/sign/SigningViewModel.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.SigningStatus +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import kotlinx.coroutines.delay + +internal class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningState.Polling(signingParameters.autoStartToken, signingParameters.startDate), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), + ) + +internal class SigningPresenter( + private val params: SigningParameters, + private val pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SigningState): SigningState { + var status by remember { mutableStateOf(null) } + var bankIdOpened by remember { mutableStateOf(false) } + + CollectEvents { event -> + when (event) { + SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.ClearNavigation -> status = null + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(2000) + pollSigningStatusUseCase.invoke(params.signingId).fold( + ifLeft = { status = SigningStatus.FAILED }, + ifRight = { polledStatus -> + if (polledStatus != SigningStatus.PENDING) { + status = polledStatus + return@LaunchedEffect + } + }, + ) + } + } + + return when (status) { + SigningStatus.SIGNED -> SigningState.Success(params.startDate) + SigningStatus.FAILED -> SigningState.Failed + else -> SigningState.Polling( + autoStartToken = params.autoStartToken, + startDate = params.startDate, + bankIdOpened = bankIdOpened, + ) + } + } +} + +internal sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent +} + +internal sealed interface SigningState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val bankIdOpened: Boolean = false, + ) : SigningState + + data class Success(val startDate: String?) : SigningState + data object Failed : SigningState +} +``` + +- [ ] **Step 4: Create SigningDestination** + +`.../ui/sign/SigningDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme + +@Composable +internal fun SigningDestination( + viewModel: SigningViewModel, + onSuccess: (startDate: String?) -> Unit, + onFailure: () -> Unit, +) { + val state = viewModel.state + val context = LocalContext.current + + LaunchedEffect(state) { + if (state is SigningState.Polling && !state.bankIdOpened) { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + } + } + + LaunchedEffect(state) { + when (state) { + is SigningState.Success -> { + viewModel.emit(SigningEvent.ClearNavigation) + onSuccess(state.startDate) + } + is SigningState.Failed -> { + viewModel.emit(SigningEvent.ClearNavigation) + onFailure() + } + is SigningState.Polling -> {} + } + } + + HedvigScaffold(topAppBarText = "") { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HedvigFullScreenCenterAlignedProgress() + HedvigText( + text = "Väntar på BankID...", + style = HedvigTheme.typography.bodyLarge, + ) + } + } +} +``` + +- [ ] **Step 5: Create success and failure destinations** + +`.../ui/success/PurchaseSuccessDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.success + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme + +@Composable +internal fun PurchaseSuccessDestination( + startDate: String?, + onClose: () -> Unit, +) { + HedvigScaffold(topAppBarText = "") { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HedvigText( + text = "Din försäkring är klar!", + style = HedvigTheme.typography.headlineMedium, + ) + if (startDate != null) { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Startdatum: $startDate", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + Spacer(Modifier.height(32.dp)) + HedvigButton( + text = "Stäng", + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} +``` + +`.../ui/failure/PurchaseFailureDestination.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.ui.failure + +import androidx.compose.runtime.Composable +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigScaffold + +@Composable +internal fun PurchaseFailureDestination( + onRetry: () -> Unit, + onClose: () -> Unit, +) { + HedvigScaffold( + navigateUp = onClose, + topAppBarText = "", + ) { + HedvigErrorSection(onButtonClick = onRetry) + } +} +``` + +- [ ] **Step 6: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add summary, signing, success, and failure screens" +``` + +--- + +## Task 6: Navigation graph and DI module + +**Files:** +- Create: `.../navigation/ApartmentPurchaseNavGraph.kt` +- Create: `.../di/ApartmentPurchaseModule.kt` + +- [ ] **Step 1: Create ApartmentPurchaseNavGraph** + +`.../navigation/ApartmentPurchaseNavGraph.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.navigation + +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +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.apartment.navigation.ApartmentPurchaseDestination.Failure +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Form +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.SelectTier +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Signing +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Success +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Summary +import com.hedvig.android.feature.purchase.apartment.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import com.hedvig.android.navigation.compose.navdestination +import com.hedvig.android.navigation.compose.navgraph +import com.hedvig.android.navigation.compose.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.apartmentPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination
{ backStackEntry -> + val graphRoute = navController + .getBackStackEntry(ApartmentPurchaseGraphDestination::class.java.name) + .toRoute() + + val viewModel: ApartmentFormViewModel = koinViewModel { parametersOf(graphRoute.productName) } + + ApartmentFormDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { popBackStack() }, + onOffersReceived = dropUnlessResumed { 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) { + val viewModel: SelectTierViewModel = koinViewModel { + parametersOf(it.toRoute().params) + } + + SelectTierDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onContinueToSummary = dropUnlessResumed { params -> + navController.navigate(Summary(params)) + }, + ) + } + + navdestination(Summary) { + val viewModel: PurchaseSummaryViewModel = koinViewModel { + parametersOf(it.toRoute().params) + } + + PurchaseSummaryDestination( + viewModel = viewModel, + navigateUp = dropUnlessResumed { navController.popBackStack() }, + onNavigateToSigning = dropUnlessResumed { params -> + navController.navigate(Signing(params)) + }, + onNavigateToFailure = dropUnlessResumed { + navController.navigate(Failure) + }, + ) + } + + navdestination(Signing) { + val route = it.toRoute() + val viewModel: SigningViewModel = koinViewModel { parametersOf(route.params) } + + SigningDestination( + viewModel = viewModel, + onSuccess = dropUnlessResumed { startDate -> + crossSellAfterFlowRepository.completedCrossSellTriggeringSelfServiceSuccessfully( + CrossSellInfoType.Purchase, + ) + navController.navigate(Success(startDate)) { + typedPopUpTo({ inclusive = true }) + } + }, + onFailure = dropUnlessResumed { + navController.navigate(Failure) + }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + onClose = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) { + finishApp() + } + }, + ) + } + } + + navdestination { + val route = it.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + onClose = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) { + finishApp() + } + }, + ) + } +} +``` + +- [ ] **Step 2: Create ApartmentPurchaseModule** + +`.../di/ApartmentPurchaseModule.kt`: +```kotlin +package com.hedvig.android.feature.purchase.apartment.di + +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { + CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) + } + single { + SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) + } + single { + AddToCartAndStartSignUseCaseImpl(apolloClient = get()) + } + single { + PollSigningStatusUseCaseImpl(apolloClient = get()) + } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } + viewModel { params -> + SelectTierViewModel(selectTierParameters = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +./gradlew :feature-purchase-apartment:compileDebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "feat: add navigation graph and DI module for apartment purchase" +``` + +--- + +## Task 7: Integration into the main app + +**Files:** +- Modify: `app/data/data-cross-sell-after-flow/.../CrossSellAfterFlowRepository.kt` +- Modify: `app/feature/feature-insurances/.../navigation/InsuranceGraph.kt` +- Modify: `app/app/src/main/kotlin/.../navigation/HedvigNavHost.kt` +- Modify: `app/app/src/main/kotlin/.../di/ApplicationModule.kt` + +- [ ] **Step 1: Add `Purchase` to CrossSellInfoType** + +In `app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt`, add a new variant to the `CrossSellInfoType` sealed class: + +```kotlin +data object Purchase : CrossSellInfoType() +``` + +Add it alongside the existing variants (`ClosedClaim`, `ChangeTier`, `Addon`, `EditCoInsured`, `MovingFlow`). + +- [ ] **Step 2: Add apartment purchase navigation parameter to InsuranceGraph** + +In `app/feature/feature-insurances/src/main/kotlin/com/hedvig/android/feature/insurances/navigation/InsuranceGraph.kt`, add a new parameter to the `insuranceGraph` function: + +```kotlin +fun NavGraphBuilder.insuranceGraph( + // ... existing parameters ... + onNavigateToApartmentPurchase: (productName: String) -> Unit, +) +``` + +In the `InsurancesDestination.Insurances` navdestination block, change the cross-sell click handler from: + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> openUrl(url) }, +``` + +to route apartment products in-app and everything else to the web: + +```kotlin +onCrossSellClick = dropUnlessResumed { url: String -> + // TODO: Once the backend provides product type in cross-sell data, + // route SE_APARTMENT_RENT and SE_APARTMENT_BRF to in-app purchase. + // For now, all cross-sells open the web URL. + openUrl(url) +}, +``` + +Note: The actual routing requires the cross-sell to carry a `productName` field. This likely needs a backend change to the `CrossSellV2` type or a way to derive the product type from the cross-sell data. For the initial integration, the in-app flow can be triggered from a deep link or a direct navigation call while the cross-sell routing is resolved with the backend team. + +- [ ] **Step 3: Register the nav graph in HedvigNavHost** + +In `app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigNavHost.kt`: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph +``` + +Add to the `insuranceGraph(...)` call: +```kotlin +onNavigateToApartmentPurchase = { productName -> + navController.navigate(ApartmentPurchaseGraphDestination(productName)) +}, +``` + +Add as a sibling graph in the NavHost (alongside `addonPurchaseNavGraph`, `changeTierGraph`, etc.): +```kotlin +apartmentPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = get(), +) +``` + +- [ ] **Step 4: Register DI module in ApplicationModule** + +In `app/app/src/main/kotlin/com/hedvig/android/app/di/ApplicationModule.kt`: + +Add import: +```kotlin +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule +``` + +Add to the `includes(listOf(...))` call: +```kotlin +apartmentPurchaseModule, +``` + +- [ ] **Step 5: Verify the full app builds** + +```bash +./gradlew :app:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. The module is auto-discovered by `settings.gradle.kts`, the DI module is included, and the nav graph is registered. + +- [ ] **Step 6: Commit** + +```bash +git add app/data/data-cross-sell-after-flow/ app/feature/feature-insurances/ app/app/ +git commit -m "feat: integrate apartment purchase flow into main app navigation" +``` + +--- + +## Task 8: Run ktlint and fix formatting + +- [ ] **Step 1: Run ktlint check on the new module** + +```bash +./gradlew :feature-purchase-apartment:ktlintCheck +``` + +- [ ] **Step 2: Fix any formatting issues** + +```bash +./gradlew :feature-purchase-apartment:ktlintFormat +``` + +- [ ] **Step 3: Run all tests** + +```bash +./gradlew :feature-purchase-apartment:test +``` + +Expected: All tests pass. + +- [ ] **Step 4: Run the full app build** + +```bash +./gradlew :app:assembleDebug +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit any formatting fixes** + +```bash +git add app/feature/feature-purchase-apartment/ +git commit -m "chore: fix ktlint formatting in apartment purchase module" +``` + +--- + +## Notes for Implementer + +### GraphQL `PricingFormData` type +The `PricingFormData` scalar type in the schema is a `Map`. When calling `ApartmentPriceIntentDataUpdateMutation`, you pass a `Map` with keys: `"street"`, `"zipCode"`, `"livingSpace"`, `"numberCoInsured"`. Apollo's custom scalar adapter handles the serialization. Check how racoon sends this data in `/Users/hugolinder/repos/racoon/apps/store/src/graphql/PriceIntentDataUpdate.graphql` for reference. + +### BankID URI pattern +The BankID app is launched via `https://app.bankid.com/?autostarttoken={token}&redirect=null`. See `app/feature/feature-login/src/main/kotlin/com/hedvig/android/feature/login/swedishlogin/BankIdState.kt` for the existing implementation including app detection and QR code fallback. For v1, the simple URI launch is sufficient. + +### Cross-sell routing +The current cross-sell click handler receives only a `storeUrl: String`. To route apartment products in-app, one of these is needed: +1. Backend adds a `productName` field to the `CrossSellV2.OtherCrossSell` type +2. Derive the product type from the `storeUrl` (e.g., parse the URL path) +3. Add a new query field + +Discuss with the backend team which approach they prefer. Until resolved, the in-app flow can be tested via direct navigation (e.g., a debug button or deep link). + +### Key reference files +- Addon purchase flow: `app/feature/feature-addon-purchase/` — simplest existing purchase pattern +- Moving flow: `app/feature/feature-movingflow/` — multi-step form with validation +- Termination flow: `app/feature/feature-terminate-insurance/` — backend-driven navigation +- Login BankID: `app/feature/feature-login/src/main/kotlin/.../BankIdState.kt` — BankID launch +- Racoon GraphQL: `/Users/hugolinder/repos/racoon/apps/store/src/graphql/` — web query patterns From 994801ed777c5aecb5cdcd66cae6ccb4b01bedba Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:31:02 +0200 Subject: [PATCH 03/22] feat: scaffold feature-purchase-apartment module with GraphQL operations Co-Authored-By: Claude Opus 4.6 (1M context) --- .../build.gradle.kts | 51 +++++++++++ .../PriceIntentConfirmMutation.graphql | 13 +++ .../graphql/PriceIntentCreateMutation.graphql | 5 ++ .../PriceIntentDataUpdateMutation.graphql | 10 +++ .../main/graphql/ProductOfferFragment.graphql | 50 +++++++++++ .../ShopSessionCartEntriesAddMutation.graphql | 10 +++ .../graphql/ShopSessionCreateMutation.graphql | 5 ++ .../graphql/ShopSessionSigningQuery.graphql | 17 ++++ .../ShopSessionStartSignMutation.graphql | 19 ++++ .../ApartmentPurchaseDestination.kt | 88 +++++++++++++++++++ 10 files changed, 268 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/build.gradle.kts create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts new file mode 100644 index 0000000000..54964b7db0 --- /dev/null +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -0,0 +1,51 @@ +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.languageCore) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationComposeTyped) + implementation(projects.navigationCore) + + testImplementation(libs.apollo.testingSupport) + testImplementation(libs.assertK) + testImplementation(libs.coroutines.test) + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(projects.apolloOctopusTest) + testImplementation(projects.apolloTest) + testImplementation(projects.coreCommonTest) + testImplementation(projects.loggingTest) + testImplementation(projects.moleculeTest) +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql new file mode 100644 index 0000000000..12a94faf01 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentConfirmMutation.graphql @@ -0,0 +1,13 @@ +mutation ApartmentPriceIntentConfirm($priceIntentId: UUID!) { + priceIntentConfirm(priceIntentId: $priceIntentId) { + priceIntent { + id + offers { + ...ApartmentProductOfferFragment + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql new file mode 100644 index 0000000000..70008bace5 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation ApartmentPriceIntentCreate($shopSessionId: UUID!, $productName: String!) { + priceIntentCreate(input: { shopSessionId: $shopSessionId, productName: $productName }) { + id + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql new file mode 100644 index 0000000000..accd624727 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/PriceIntentDataUpdateMutation.graphql @@ -0,0 +1,10 @@ +mutation ApartmentPriceIntentDataUpdate($priceIntentId: UUID!, $data: PricingFormData!) { + priceIntentDataUpdate(priceIntentId: $priceIntentId, data: $data) { + priceIntent { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql new file mode 100644 index 0000000000..d436604c50 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ProductOfferFragment.graphql @@ -0,0 +1,50 @@ +fragment ApartmentProductOfferFragment 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-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql new file mode 100644 index 0000000000..4591859668 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCartEntriesAddMutation.graphql @@ -0,0 +1,10 @@ +mutation ApartmentCartEntriesAdd($shopSessionId: UUID!, $offerIds: [UUID!]!) { + shopSessionCartEntriesAdd(input: { shopSessionId: $shopSessionId, offerIds: $offerIds }) { + shopSession { + id + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql new file mode 100644 index 0000000000..9e124e87c5 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionCreateMutation.graphql @@ -0,0 +1,5 @@ +mutation ApartmentShopSessionCreate($countryCode: CountryCode!) { + shopSessionCreate(input: { countryCode: $countryCode }) { + id + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql new file mode 100644 index 0000000000..bc326afc0f --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionSigningQuery.graphql @@ -0,0 +1,17 @@ +query ApartmentShopSessionSigning($signingId: UUID!) { + shopSessionSigning(id: $signingId) { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + completion { + authorizationCode + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql new file mode 100644 index 0000000000..0067b7708d --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/graphql/ShopSessionStartSignMutation.graphql @@ -0,0 +1,19 @@ +mutation ApartmentStartSign($shopSessionId: UUID!) { + shopSessionStartSign(shopSessionId: $shopSessionId) { + signing { + id + status + seBankidProperties { + autoStartToken + liveQrCodeData + bankidAppOpened + } + userError { + message + } + } + userError { + message + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt new file mode 100644 index 0000000000..c03968519d --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseDestination.kt @@ -0,0 +1,88 @@ +package com.hedvig.android.feature.purchase.apartment.navigation + +import com.hedvig.android.navigation.common.Destination +import com.hedvig.android.navigation.common.DestinationNavTypeAware +import kotlin.reflect.KType +import kotlin.reflect.typeOf +import kotlinx.serialization.Serializable + +@Serializable +data class ApartmentPurchaseGraphDestination( + val productName: String, +) : Destination + +internal sealed interface ApartmentPurchaseDestination { + @Serializable + data object Form : ApartmentPurchaseDestination, Destination + + @Serializable + data class SelectTier( + val params: SelectTierParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Summary( + val params: SummaryParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Signing( + val params: SigningParameters, + ) : ApartmentPurchaseDestination, Destination { + companion object : DestinationNavTypeAware { + override val typeList: List = listOf(typeOf()) + } + } + + @Serializable + data class Success( + val startDate: String?, + ) : ApartmentPurchaseDestination, Destination + + @Serializable + data object Failure : ApartmentPurchaseDestination, Destination +} + +@Serializable +internal data class TierOfferData( + val offerId: String, + val tierDisplayName: String, + val tierDescription: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val netAmount: Double, + val netCurrencyCode: String, + val usps: List, + val exposureDisplayName: String, + val deductibleDisplayName: String?, + val hasDiscount: Boolean, +) + +@Serializable +internal data class SelectTierParameters( + val shopSessionId: String, + val offers: List, + val productDisplayName: String, +) + +@Serializable +internal data class SummaryParameters( + val shopSessionId: String, + val selectedOffer: TierOfferData, + val productDisplayName: String, +) + +@Serializable +internal data class SigningParameters( + val signingId: String, + val autoStartToken: String, + val startDate: String?, +) From 8df76d63a29b1bc1558e106d24c613d36a7983a5 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:37:34 +0200 Subject: [PATCH 04/22] feat: add domain models and use cases for apartment purchase Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data/AddToCartAndStartSignUseCase.kt | 68 +++++++++++++ .../CreateSessionAndPriceIntentUseCase.kt | 48 +++++++++ .../data/PollSigningStatusUseCase.kt | 45 +++++++++ .../apartment/data/PurchaseApartmentModels.kt | 36 +++++++ .../data/SubmitFormAndGetOffersUseCase.kt | 99 +++++++++++++++++++ .../CreateSessionAndPriceIntentUseCaseTest.kt | 80 +++++++++++++++ 6 files changed, 376 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt create mode 100644 app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt new file mode 100644 index 0000000000..ef7705d31e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/AddToCartAndStartSignUseCase.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.feature.purchase.apartment.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.ApartmentCartEntriesAddMutation +import octopus.ApartmentStartSignMutation + +internal interface AddToCartAndStartSignUseCase { + suspend fun invoke(shopSessionId: String, offerId: String): Either +} + +internal class AddToCartAndStartSignUseCaseImpl( + private val apolloClient: ApolloClient, +) : AddToCartAndStartSignUseCase { + override suspend fun invoke(shopSessionId: String, offerId: String): Either { + return either { + val cartResult = apolloClient + .mutation(ApartmentCartEntriesAddMutation(shopSessionId = shopSessionId, offerIds = listOf(offerId))) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to add to cart: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCartEntriesAdd }, + ) + + if (cartResult.userError != null) { + raise(ErrorMessage(cartResult.userError?.message)) + } + + val signResult = apolloClient + .mutation(ApartmentStartSignMutation(shopSessionId = shopSessionId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to start signing: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionStartSign }, + ) + + if (signResult.userError != null) { + raise(ErrorMessage(signResult.userError?.message)) + } + + val signing = signResult.signing ?: run { + logcat(LogPriority.ERROR) { "No signing session returned" } + raise(ErrorMessage()) + } + + val autoStartToken = signing.seBankidProperties?.autoStartToken ?: run { + logcat(LogPriority.ERROR) { "No BankID autoStartToken in signing response" } + raise(ErrorMessage()) + } + + SigningStart( + signingId = signing.id, + autoStartToken = autoStartToken, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt new file mode 100644 index 0000000000..27331943b8 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/CreateSessionAndPriceIntentUseCase.kt @@ -0,0 +1,48 @@ +package com.hedvig.android.feature.purchase.apartment.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.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode + +internal interface CreateSessionAndPriceIntentUseCase { + suspend fun invoke(productName: String): Either +} + +internal class CreateSessionAndPriceIntentUseCaseImpl( + private val apolloClient: ApolloClient, +) : CreateSessionAndPriceIntentUseCase { + override suspend fun invoke(productName: String): Either { + return either { + val shopSessionId = apolloClient + .mutation(ApartmentShopSessionCreateMutation(CountryCode.SE)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create shop session: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.shopSessionCreate.id }, + ) + + val priceIntentId = apolloClient + .mutation(ApartmentPriceIntentCreateMutation(shopSessionId = shopSessionId, productName = productName)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to create price intent: $it" } + raise(ErrorMessage()) + }, + ifRight = { it.priceIntentCreate.id }, + ) + + SessionAndIntent(shopSessionId = shopSessionId, priceIntentId = priceIntentId) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt new file mode 100644 index 0000000000..fac7935460 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -0,0 +1,45 @@ +package com.hedvig.android.feature.purchase.apartment.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.ApartmentShopSessionSigningQuery +import octopus.type.ShopSessionSigningStatus + +internal interface PollSigningStatusUseCase { + suspend fun invoke(signingId: String): Either +} + +internal class PollSigningStatusUseCaseImpl( + private val apolloClient: ApolloClient, +) : PollSigningStatusUseCase { + override suspend fun invoke(signingId: String): Either { + return either { + apolloClient + .query(ApartmentShopSessionSigningQuery(signingId = signingId)) + .safeExecute() + .fold( + ifLeft = { + logcat(LogPriority.ERROR) { "Failed to poll signing status: $it" } + raise(ErrorMessage()) + }, + ifRight = { result -> + when (result.shopSessionSigning.status) { + ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED + + ShopSessionSigningStatus.PENDING, + ShopSessionSigningStatus.CREATING, + ShopSessionSigningStatus.UNKNOWN__, + -> SigningStatus.PENDING + } + }, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt new file mode 100644 index 0000000000..72d42e8d31 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt @@ -0,0 +1,36 @@ +package com.hedvig.android.feature.purchase.apartment.data + +import com.hedvig.android.core.uidata.UiMoney + +internal data class SessionAndIntent( + val shopSessionId: String, + val priceIntentId: String, +) + +internal data class ApartmentOffers( + val productDisplayName: String, + val offers: List, +) + +internal data class ApartmentTierOffer( + 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, +) + +internal data class SigningStart( + val signingId: String, + val autoStartToken: String, +) + +internal enum class SigningStatus { + PENDING, + SIGNED, + FAILED, +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt new file mode 100644 index 0000000000..19d2d7c4fb --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/SubmitFormAndGetOffersUseCase.kt @@ -0,0 +1,99 @@ +package com.hedvig.android.feature.purchase.apartment.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.ApartmentPriceIntentConfirmMutation +import octopus.ApartmentPriceIntentDataUpdateMutation +import octopus.fragment.ApartmentProductOfferFragment + +internal interface SubmitFormAndGetOffersUseCase { + suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either +} + +internal class SubmitFormAndGetOffersUseCaseImpl( + private val apolloClient: ApolloClient, +) : SubmitFormAndGetOffersUseCase { + override suspend fun invoke( + priceIntentId: String, + street: String, + zipCode: String, + livingSpace: Int, + numberCoInsured: Int, + ): Either { + return either { + val formData = buildMap { + put("street", street) + put("zipCode", zipCode) + put("livingSpace", livingSpace) + put("numberCoInsured", numberCoInsured) + } + + val updateResult = apolloClient + .mutation(ApartmentPriceIntentDataUpdateMutation(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(ApartmentPriceIntentConfirmMutation(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()) + } + + ApartmentOffers( + productDisplayName = offers.first().variant.displayName, + offers = offers.map { it.toTierOffer() }, + ) + } + } +} + +internal fun ApartmentProductOfferFragment.toTierOffer(): ApartmentTierOffer { + return ApartmentTierOffer( + 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, + ) +} diff --git a/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt b/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt new file mode 100644 index 0000000000..e6540ac8ea --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/test/kotlin/data/CreateSessionAndPriceIntentUseCaseTest.kt @@ -0,0 +1,80 @@ +package data + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import assertk.assertions.prop +import com.apollographql.apollo.annotations.ApolloExperimental +import com.apollographql.apollo.api.Error +import com.apollographql.apollo.testing.registerTestResponse +import com.hedvig.android.apollo.octopus.test.OctopusFakeResolver +import com.hedvig.android.apollo.test.TestApolloClientRule +import com.hedvig.android.apollo.test.TestNetworkTransportType +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.test.isLeft +import com.hedvig.android.core.common.test.isRight +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.logger.TestLogcatLoggingRule +import kotlinx.coroutines.test.runTest +import octopus.ApartmentPriceIntentCreateMutation +import octopus.ApartmentShopSessionCreateMutation +import octopus.type.CountryCode +import octopus.type.buildPriceIntent +import octopus.type.buildShopSession +import org.junit.Rule +import org.junit.Test + +class CreateSessionAndPriceIntentUseCaseTest { + @get:Rule + val testLogcatLogger = TestLogcatLoggingRule() + + @get:Rule + val testApolloClientRule = TestApolloClientRule(TestNetworkTransportType.MAP) + + @OptIn(ApolloExperimental::class) + @Test + fun `successful session and price intent creation returns both ids`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = ApartmentShopSessionCreateMutation.Data(OctopusFakeResolver) { + shopSessionCreate = buildShopSession { + id = "session-123" + } + }, + ) + registerTestResponse( + operation = ApartmentPriceIntentCreateMutation( + shopSessionId = "session-123", + productName = "SE_APARTMENT_RENT", + ), + data = ApartmentPriceIntentCreateMutation.Data(OctopusFakeResolver) { + priceIntentCreate = buildPriceIntent { + id = "intent-456" + } + }, + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isRight().isEqualTo(SessionAndIntent("session-123", "intent-456")) + } + + @OptIn(ApolloExperimental::class) + @Test + fun `network error on session creation returns ErrorMessage`() = runTest { + val apolloClient = testApolloClientRule.apolloClient.apply { + registerTestResponse( + operation = ApartmentShopSessionCreateMutation(CountryCode.SE), + data = null, + errors = listOf(Error.Builder(message = "Network error").build()), + ) + } + + val sut = CreateSessionAndPriceIntentUseCaseImpl(apolloClient) + val result = sut.invoke("SE_APARTMENT_RENT") + assertThat(result).isLeft().prop(ErrorMessage::message).isNull() + } +} From c7fa06eda0b1f5a8f16c17b83bf03eb38e921606 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:41:06 +0200 Subject: [PATCH 05/22] feat: add apartment purchase form screen with validation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/form/ApartmentFormDestination.kt | 169 ++++++++++++++++ .../ui/form/ApartmentFormViewModel.kt | 183 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt new file mode 100644 index 0000000000..032b2250cd --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -0,0 +1,169 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigStepper +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperSize.Medium +import com.hedvig.android.design.system.hedvig.StepperDefaults.StepperStyle.Labeled +import com.hedvig.android.feature.purchase.apartment.data.ApartmentOffers + +@Composable +internal fun ApartmentFormDestination( + viewModel: ApartmentFormViewModel, + navigateUp: () -> Unit, + onOffersReceived: (shopSessionId: String, offers: ApartmentOffers) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val offersData = uiState.offersToNavigate + if (offersData != null) { + LaunchedEffect(offersData) { + viewModel.emit(ApartmentFormEvent.ClearNavigation) + onOffersReceived(offersData.shopSessionId, offersData.offers) + } + } + HedvigScaffold( + navigateUp = navigateUp, + topAppBarText = "Hemförsäkring", + ) { + when { + uiState.isLoadingSession -> HedvigFullScreenCenterAlignedProgress() + uiState.loadSessionError -> HedvigErrorSection( + onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + else -> ApartmentFormContent( + uiState = uiState, + onStreetChanged = { viewModel.emit(ApartmentFormEvent.UpdateStreet(it)) }, + onZipCodeChanged = { viewModel.emit(ApartmentFormEvent.UpdateZipCode(it)) }, + onLivingSpaceChanged = { viewModel.emit(ApartmentFormEvent.UpdateLivingSpace(it)) }, + onNumberCoInsuredChanged = { viewModel.emit(ApartmentFormEvent.UpdateNumberCoInsured(it)) }, + onSubmit = { viewModel.emit(ApartmentFormEvent.Submit) }, + onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + } + } +} + +@Composable +private fun ApartmentFormContent( + uiState: ApartmentFormState, + onStreetChanged: (String) -> Unit, + onZipCodeChanged: (String) -> Unit, + onLivingSpaceChanged: (String) -> Unit, + onNumberCoInsuredChanged: (Int) -> Unit, + onSubmit: () -> Unit, + onRetry: () -> Unit, +) { + if (uiState.submitError != null) { + HedvigErrorSection( + onButtonClick = onRetry, + ) + return + } + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(16.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + HedvigTextField( + text = uiState.street, + onValueChange = onStreetChanged, + labelText = "Adress", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = uiState.streetError.toErrorState(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + enabled = !uiState.isSubmitting, + ) + HedvigTextField( + text = uiState.zipCode, + onValueChange = { value -> + if (value.all { it.isDigit() }) { + onZipCodeChanged(value) + } + }, + labelText = "Postnummer", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = uiState.zipCodeError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + enabled = !uiState.isSubmitting, + ) + HedvigTextField( + text = uiState.livingSpace, + onValueChange = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) { + onLivingSpaceChanged(value) + } + }, + labelText = "Boyta (kvm)", + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + errorState = uiState.livingSpaceError.toErrorState(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + enabled = !uiState.isSubmitting, + ) + HedvigStepper( + text = when (uiState.numberCoInsured) { + 0 -> "Bara du" + else -> "Du + ${uiState.numberCoInsured}" + }, + stepperSize = Medium, + stepperStyle = Labeled("Antal medförsäkrade"), + onMinusClick = { onNumberCoInsuredChanged(uiState.numberCoInsured - 1) }, + onPlusClick = { onNumberCoInsuredChanged(uiState.numberCoInsured + 1) }, + isPlusEnabled = !uiState.isSubmitting && uiState.numberCoInsured < 5, + isMinusEnabled = !uiState.isSubmitting && uiState.numberCoInsured > 0, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Beräkna pris", + onClick = onSubmit, + enabled = !uiState.isSubmitting, + isLoading = uiState.isSubmitting, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } +} + +private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { + return if (this != null) { + HedvigTextFieldDefaults.ErrorState.Error.WithMessage(this) + } else { + HedvigTextFieldDefaults.ErrorState.NoError + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt new file mode 100644 index 0000000000..90c74d899d --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt @@ -0,0 +1,183 @@ +package com.hedvig.android.feature.purchase.apartment.ui.form + +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.apartment.data.ApartmentOffers +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.SessionAndIntent +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class ApartmentFormViewModel( + productName: String, + createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculeViewModel( + initialState = ApartmentFormState(), + presenter = ApartmentFormPresenter(productName, createSessionAndPriceIntentUseCase, submitFormAndGetOffersUseCase), + ) + +internal sealed interface ApartmentFormEvent { + data class UpdateStreet(val value: String) : ApartmentFormEvent + data class UpdateZipCode(val value: String) : ApartmentFormEvent + data class UpdateLivingSpace(val value: String) : ApartmentFormEvent + data class UpdateNumberCoInsured(val value: Int) : ApartmentFormEvent + data object Submit : ApartmentFormEvent + data object ClearNavigation : ApartmentFormEvent + data object Retry : ApartmentFormEvent +} + +internal data class ApartmentFormState( + val street: String = "", + val zipCode: String = "", + val livingSpace: String = "", + val numberCoInsured: Int = 0, + val streetError: String? = null, + val zipCodeError: String? = null, + val livingSpaceError: 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: ApartmentOffers, +) + +private class ApartmentFormPresenter( + private val productName: String, + private val createSessionAndPriceIntentUseCase: CreateSessionAndPriceIntentUseCase, + private val submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: ApartmentFormState, + ): ApartmentFormState { + 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 shouldSubmit by remember { mutableStateOf(false) } + + CollectEvents { event -> + when (event) { + is ApartmentFormEvent.UpdateStreet -> { + currentState = currentState.copy(street = event.value, streetError = null) + } + is ApartmentFormEvent.UpdateZipCode -> { + currentState = currentState.copy(zipCode = event.value, zipCodeError = null) + } + is ApartmentFormEvent.UpdateLivingSpace -> { + currentState = currentState.copy(livingSpace = event.value, livingSpaceError = null) + } + is ApartmentFormEvent.UpdateNumberCoInsured -> { + currentState = currentState.copy(numberCoInsured = event.value.coerceAtLeast(0)) + } + ApartmentFormEvent.Submit -> { + val errors = validate(currentState) + if (errors.hasErrors()) { + currentState = currentState.copy( + streetError = errors.streetError, + zipCodeError = errors.zipCodeError, + livingSpaceError = errors.livingSpaceError, + ) + } else { + shouldSubmit = true + submitIteration++ + } + } + ApartmentFormEvent.ClearNavigation -> { + currentState = currentState.copy(offersToNavigate = null) + } + ApartmentFormEvent.Retry -> { + if (sessionAndIntent == null) { + currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) + sessionLoadIteration++ + } else { + currentState = currentState.copy(submitError = null) + } + } + } + } + + LaunchedEffect(sessionLoadIteration) { + currentState = currentState.copy(isLoadingSession = true, loadSessionError = false) + createSessionAndPriceIntentUseCase.invoke(productName).fold( + ifLeft = { + currentState = currentState.copy(isLoadingSession = false, loadSessionError = true) + }, + ifRight = { result -> + sessionAndIntent = result + currentState = currentState.copy(isLoadingSession = false, loadSessionError = false) + }, + ) + } + + LaunchedEffect(submitIteration) { + if (!shouldSubmit) return@LaunchedEffect + val session = sessionAndIntent ?: return@LaunchedEffect + shouldSubmit = false + currentState = currentState.copy(isSubmitting = true, submitError = null) + submitFormAndGetOffersUseCase.invoke( + priceIntentId = session.priceIntentId, + street = currentState.street, + zipCode = currentState.zipCode, + livingSpace = currentState.livingSpace.toInt(), + numberCoInsured = currentState.numberCoInsured, + ).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 livingSpaceError: String?, +) { + fun hasErrors(): Boolean = streetError != null || zipCodeError != null || livingSpaceError != null +} + +private fun validate(state: ApartmentFormState): ValidationErrors { + return ValidationErrors( + streetError = if (state.street.isBlank()) "Ange en adress" else null, + zipCodeError = when { + state.zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !state.zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + else -> null + }, + livingSpaceError = when { + state.livingSpace.isBlank() -> "Ange boyta" + state.livingSpace.toIntOrNull() == null -> "Ange ett giltigt tal" + state.livingSpace.toInt() <= 0 -> "Boytan måste vara större än 0" + else -> null + }, + ) +} From 2c011540efad37188d91795ebddbd6f7194fd6b4 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:42:12 +0200 Subject: [PATCH 06/22] feat: add tier selection screen for apartment purchase Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/offer/SelectTierDestination.kt | 211 ++++++++++++++++++ .../apartment/ui/offer/SelectTierViewModel.kt | 74 ++++++ 2 files changed, 285 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt new file mode 100644 index 0000000000..dde8f12142 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -0,0 +1,211 @@ +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.TopAppBarWithBack +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +@Composable +internal fun SelectTierDestination( + viewModel: SelectTierViewModel, + navigateUp: () -> Unit, + onContinueToSummary: (SummaryParameters) -> Unit, +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + if (uiState.summaryToNavigate != null) { + LaunchedEffect(uiState.summaryToNavigate) { + viewModel.emit(SelectTierEvent.ClearNavigation) + onContinueToSummary(uiState.summaryToNavigate) + } + } + SelectTierScreen( + uiState = uiState, + navigateUp = navigateUp, + onSelectOffer = { viewModel.emit(SelectTierEvent.SelectOffer(it)) }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierScreen( + uiState: SelectTierUiState, + navigateUp: () -> Unit, + onSelectOffer: (String) -> Unit, + onContinue: () -> Unit, +) { + Surface( + color = HedvigTheme.colorScheme.backgroundPrimary, + modifier = Modifier.fillMaxSize(), + ) { + Column { + TopAppBarWithBack( + title = "", + onClick = navigateUp, + ) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Anpassa din f\u00f6rs\u00e4kring", + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(24.dp)) + for ((index, offer) in uiState.offers.withIndex()) { + val isSelected = offer.offerId == uiState.selectedOfferId + TierCard( + offer = offer, + isSelected = isSelected, + onSelect = { onSelectOffer(offer.offerId) }, + ) + if (index < uiState.offers.lastIndex) { + Spacer(Modifier.height(12.dp)) + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { onContinue() }, + enabled = uiState.offers.any { it.offerId == uiState.selectedOfferId }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } + } + } +} + +@Composable +private fun TierCard( + offer: TierOfferData, + isSelected: Boolean, + onSelect: () -> Unit, +) { + HedvigCard( + onClick = onSelect, + color = if (isSelected) { + HedvigTheme.colorScheme.surfacePrimary + } else { + HedvigTheme.colorScheme.surfacePrimary + }, + borderColor = if (isSelected) { + HedvigTheme.colorScheme.signalGreenElement + } else { + HedvigTheme.colorScheme.borderSecondary + }, + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodyLarge, + ) + HedvigText( + text = formatPrice(offer.netAmount, offer.netCurrencyCode), + style = HedvigTheme.typography.bodyLarge, + ) + } + AnimatedVisibility( + visible = isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + if (offer.usps.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + for (usp in offer.usps) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + HedvigIcons.Checkmark, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = HedvigTheme.colorScheme.signalGreenElement, + ) + Spacer(Modifier.width(8.dp)) + HedvigText( + text = usp, + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + } + } + AnimatedVisibility( + visible = !isSelected, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "V\u00e4lj ${offer.tierDisplayName}", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + } + } +} + +private fun formatPrice(amount: Double, currencyCode: String): String { + val format = NumberFormat.getCurrencyInstance(Locale.of("sv", "SE")) + format.currency = Currency.getInstance(currencyCode) + format.maximumFractionDigits = 0 + return "${format.format(amount)}/m\u00e5n" +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt new file mode 100644 index 0000000000..e28fc6d485 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt @@ -0,0 +1,74 @@ +package com.hedvig.android.feature.purchase.apartment.ui.offer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class SelectTierViewModel( + params: SelectTierParameters, +) : MoleculeViewModel( + SelectTierUiState( + offers = params.offers, + selectedOfferId = params.offers.firstOrNull { it.tierDisplayName == "Standard" }?.offerId + ?: params.offers.firstOrNull()?.offerId + ?: "", + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = null, + ), + SelectTierPresenter(params), +) + +internal class SelectTierPresenter( + private val params: SelectTierParameters, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { + var selectedOfferId by mutableStateOf(lastState.selectedOfferId) + var summaryToNavigate: SummaryParameters? by mutableStateOf(lastState.summaryToNavigate) + + CollectEvents { event -> + when (event) { + is SelectTierEvent.SelectOffer -> selectedOfferId = event.offerId + SelectTierEvent.Continue -> { + val selectedOffer = params.offers.first { it.offerId == selectedOfferId } + summaryToNavigate = SummaryParameters( + shopSessionId = params.shopSessionId, + selectedOffer = selectedOffer, + productDisplayName = params.productDisplayName, + ) + } + SelectTierEvent.ClearNavigation -> summaryToNavigate = null + } + } + + return SelectTierUiState( + offers = params.offers, + selectedOfferId = selectedOfferId, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = summaryToNavigate, + ) + } +} + +internal data class SelectTierUiState( + val offers: List, + val selectedOfferId: String, + val shopSessionId: String, + val productDisplayName: String, + val summaryToNavigate: SummaryParameters?, +) + +internal sealed interface SelectTierEvent { + data class SelectOffer(val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent +} From 496d02f639472569e09fe5af682ba04673d44afc Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:42:45 +0200 Subject: [PATCH 07/22] feat: add summary, signing, success, and failure screens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/failure/PurchaseFailureDestination.kt | 23 ++++ .../apartment/ui/sign/SigningDestination.kt | 43 +++++++ .../apartment/ui/sign/SigningViewModel.kt | 96 +++++++++++++++ .../ui/success/PurchaseSuccessDestination.kt | 55 +++++++++ .../ui/summary/PurchaseSummaryDestination.kt | 111 ++++++++++++++++++ .../ui/summary/PurchaseSummaryViewModel.kt | 98 ++++++++++++++++ 6 files changed, 426 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt new file mode 100644 index 0000000000..0af14e7fa3 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt @@ -0,0 +1,23 @@ +package com.hedvig.android.feature.purchase.apartment.ui.failure + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.TopAppBarActionType + +@Composable +internal fun PurchaseFailureDestination( + onRetry: () -> Unit, + close: () -> Unit, +) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + ) { + HedvigErrorSection( + onButtonClick = onRetry, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt new file mode 100644 index 0000000000..11db658457 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -0,0 +1,43 @@ +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress + +@Composable +internal fun SigningDestination( + viewModel: SigningViewModel, + navigateToSuccess: (startDate: String?) -> Unit, + navigateToFailure: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(Unit) { + val token = (uiState as? SigningUiState.Polling)?.autoStartToken ?: return@LaunchedEffect + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=$token&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + } + + when (val state = uiState) { + is SigningUiState.Polling -> HedvigFullScreenCenterAlignedProgress() + is SigningUiState.Success -> { + LaunchedEffect(state) { + navigateToSuccess(state.startDate) + } + HedvigFullScreenCenterAlignedProgress() + } + is SigningUiState.Failed -> { + LaunchedEffect(state) { + navigateToFailure() + } + HedvigFullScreenCenterAlignedProgress() + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt new file mode 100644 index 0000000000..8987f68921 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -0,0 +1,96 @@ +package com.hedvig.android.feature.purchase.apartment.ui.sign + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.SigningStatus +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import kotlinx.coroutines.delay + +internal class SigningViewModel( + signingParameters: SigningParameters, + pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculeViewModel( + initialState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + bankIdOpened = false, + ), + presenter = SigningPresenter( + signingParameters, + pollSigningStatusUseCase, + ), + ) + +internal class SigningPresenter( + private val signingParameters: SigningParameters, + private val pollSigningStatusUseCase: PollSigningStatusUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present(lastState: SigningUiState): SigningUiState { + var bankIdOpened by remember { mutableStateOf((lastState as? SigningUiState.Polling)?.bankIdOpened ?: false) } + var currentState by remember { mutableStateOf(lastState) } + + CollectEvents { event -> + when (event) { + SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.ClearNavigation -> { + // Reset state if needed + } + } + } + + LaunchedEffect(Unit) { + while (true) { + delay(2_000) + pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( + ifLeft = { + currentState = SigningUiState.Failed + return@LaunchedEffect + }, + ifRight = { status -> + when (status) { + SigningStatus.SIGNED -> { + currentState = SigningUiState.Success(startDate = signingParameters.startDate) + return@LaunchedEffect + } + SigningStatus.FAILED -> { + currentState = SigningUiState.Failed + return@LaunchedEffect + } + SigningStatus.PENDING -> { /* continue polling */ } + } + }, + ) + } + } + + return when (val state = currentState) { + is SigningUiState.Polling -> state.copy(bankIdOpened = bankIdOpened) + else -> currentState + } + } +} + +internal sealed interface SigningUiState { + data class Polling( + val autoStartToken: String, + val startDate: String?, + val bankIdOpened: Boolean, + ) : SigningUiState + + data class Success(val startDate: String?) : SigningUiState + data object Failed : SigningUiState +} + +internal sealed interface SigningEvent { + data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt new file mode 100644 index 0000000000..b1ccb653ed --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt @@ -0,0 +1,55 @@ +package com.hedvig.android.feature.purchase.apartment.ui.success + +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.TopAppBarActionType + +@Composable +internal fun PurchaseSuccessDestination( + startDate: String?, + close: () -> Unit, +) { + HedvigScaffold( + navigateUp = close, + topAppBarActionType = TopAppBarActionType.CLOSE, + itemsColumnHorizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.weight(1f)) + HedvigText( + text = "Din försäkring är klar!", + style = HedvigTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + if (startDate != null) { + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Startdatum: $startDate", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.weight(1f)) + HedvigButton( + text = "Stäng", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = true, + onClick = close, + ) + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt new file mode 100644 index 0000000000..c2c4618d7e --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt @@ -0,0 +1,111 @@ +package com.hedvig.android.feature.purchase.apartment.ui.summary + +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters + +@Composable +internal fun PurchaseSummaryDestination( + viewModel: PurchaseSummaryViewModel, + navigateUp: () -> Unit, + navigateToSigning: (SigningParameters) -> Unit, + navigateToFailure: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.signingToNavigate) { + val signing = uiState.signingToNavigate ?: return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + navigateToSigning(signing) + } + + LaunchedEffect(uiState.navigateToFailure) { + if (!uiState.navigateToFailure) return@LaunchedEffect + viewModel.emit(PurchaseSummaryEvent.ClearNavigation) + navigateToFailure() + } + + PurchaseSummaryScreen( + params = uiState.params, + isSubmitting = uiState.isSubmitting, + navigateUp = navigateUp, + onConfirm = { viewModel.emit(PurchaseSummaryEvent.Confirm) }, + ) +} + +@Composable +private fun PurchaseSummaryScreen( + params: SummaryParameters, + isSubmitting: Boolean, + navigateUp: () -> Unit, + onConfirm: () -> Unit, +) { + HedvigScaffold(navigateUp) { + val offer = params.selectedOffer + HedvigCard(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + HedvigText( + text = params.productDisplayName, + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = offer.tierDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(8.dp)) + HedvigText( + text = offer.exposureDisplayName, + style = HedvigTheme.typography.bodySmall, + ) + Spacer(Modifier.height(16.dp)) + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { + HedvigText( + text = "Pris", + style = HedvigTheme.typography.bodySmall, + ) + }, + spaceBetween = 8.dp, + endSlot = { + HedvigText( + text = "${offer.netAmount} ${offer.netCurrencyCode}/mån", + style = HedvigTheme.typography.bodySmall, + ) + }, + ) + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = "Signera med BankID", + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + buttonStyle = Primary, + buttonSize = Large, + enabled = !isSubmitting, + isLoading = isSubmitting, + onClick = onConfirm, + ) + Spacer(Modifier.height(16.dp)) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt new file mode 100644 index 0000000000..1f3383edcf --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt @@ -0,0 +1,98 @@ +package com.hedvig.android.feature.purchase.apartment.ui.summary + +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.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters +import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel + +internal class PurchaseSummaryViewModel( + summaryParameters: SummaryParameters, + addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculeViewModel( + initialState = PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = false, + signingToNavigate = null, + navigateToFailure = false, + ), + presenter = PurchaseSummaryPresenter( + summaryParameters, + addToCartAndStartSignUseCase, + ), + ) + +internal class PurchaseSummaryPresenter( + private val summaryParameters: SummaryParameters, + private val addToCartAndStartSignUseCase: AddToCartAndStartSignUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PurchaseSummaryUiState, + ): PurchaseSummaryUiState { + var confirmIteration by remember { mutableIntStateOf(0) } + var isSubmitting by remember { mutableStateOf(lastState.isSubmitting) } + var signingToNavigate by remember { mutableStateOf(lastState.signingToNavigate) } + var navigateToFailure by remember { mutableStateOf(lastState.navigateToFailure) } + + CollectEvents { event -> + when (event) { + PurchaseSummaryEvent.Confirm -> confirmIteration++ + PurchaseSummaryEvent.ClearNavigation -> { + signingToNavigate = null + navigateToFailure = false + } + } + } + + LaunchedEffect(confirmIteration) { + if (confirmIteration > 0) { + isSubmitting = true + addToCartAndStartSignUseCase.invoke( + summaryParameters.shopSessionId, + summaryParameters.selectedOffer.offerId, + ).fold( + ifLeft = { + isSubmitting = false + navigateToFailure = true + }, + ifRight = { signingStart -> + isSubmitting = false + signingToNavigate = SigningParameters( + signingId = signingStart.signingId, + autoStartToken = signingStart.autoStartToken, + startDate = null, + ) + }, + ) + } + } + + return PurchaseSummaryUiState( + params = summaryParameters, + isSubmitting = isSubmitting, + signingToNavigate = signingToNavigate, + navigateToFailure = navigateToFailure, + ) + } +} + +internal data class PurchaseSummaryUiState( + val params: SummaryParameters, + val isSubmitting: Boolean, + val signingToNavigate: SigningParameters?, + val navigateToFailure: Boolean, +) + +internal sealed interface PurchaseSummaryEvent { + data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent +} From 72e128e039aaa12bada4affdf713fdea8bccd2a6 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:47:13 +0200 Subject: [PATCH 08/22] feat: add navigation graph and DI module for apartment purchase Co-Authored-By: Claude Opus 4.6 (1M context) --- .../flow/CrossSellAfterFlowRepository.kt | 5 + .../apartment/di/ApartmentPurchaseModule.kt | 46 ++++++ .../navigation/ApartmentPurchaseNavGraph.kt | 142 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt create mode 100644 app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt diff --git a/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt b/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt index f3ffd2f1d6..40f05139b3 100644 --- a/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt +++ b/app/data/data-cross-sell-after-flow/src/main/kotlin/com/hedvig/android/data/cross/sell/after/flow/CrossSellAfterFlowRepository.kt @@ -67,6 +67,11 @@ sealed class CrossSellInfoType() { override val loggableName: String = "moveFlow" override val extraInfo: Map? = null } + + data object Purchase : CrossSellInfoType() { + override val loggableName: String = "purchase" + override val extraInfo: Map? = null + } } class CrossSellAfterFlowRepositoryImpl() : CrossSellAfterFlowRepository { diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt new file mode 100644 index 0000000000..8255554230 --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/di/ApartmentPurchaseModule.kt @@ -0,0 +1,46 @@ +package com.hedvig.android.feature.purchase.apartment.di + +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCase +import com.hedvig.android.feature.purchase.apartment.data.AddToCartAndStartSignUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCase +import com.hedvig.android.feature.purchase.apartment.data.CreateSessionAndPriceIntentUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCase +import com.hedvig.android.feature.purchase.apartment.data.PollSigningStatusUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCase +import com.hedvig.android.feature.purchase.apartment.data.SubmitFormAndGetOffersUseCaseImpl +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val apartmentPurchaseModule = module { + single { CreateSessionAndPriceIntentUseCaseImpl(apolloClient = get()) } + single { SubmitFormAndGetOffersUseCaseImpl(apolloClient = get()) } + single { AddToCartAndStartSignUseCaseImpl(apolloClient = get()) } + single { PollSigningStatusUseCaseImpl(apolloClient = get()) } + + viewModel { params -> + ApartmentFormViewModel( + productName = params.get(), + createSessionAndPriceIntentUseCase = get(), + submitFormAndGetOffersUseCase = get(), + ) + } + viewModel { params -> + SelectTierViewModel(params = params.get()) + } + viewModel { params -> + PurchaseSummaryViewModel( + summaryParameters = params.get(), + addToCartAndStartSignUseCase = get(), + ) + } + viewModel { params -> + SigningViewModel( + signingParameters = params.get(), + pollSigningStatusUseCase = get(), + ) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt new file mode 100644 index 0000000000..7c7ca977ef --- /dev/null +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt @@ -0,0 +1,142 @@ +package com.hedvig.android.feature.purchase.apartment.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.apartment.navigation.ApartmentPurchaseDestination.Failure +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Form +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.SelectTier +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Signing +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Success +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseDestination.Summary +import com.hedvig.android.feature.purchase.apartment.ui.failure.PurchaseFailureDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormDestination +import com.hedvig.android.feature.purchase.apartment.ui.form.ApartmentFormViewModel +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierDestination +import com.hedvig.android.feature.purchase.apartment.ui.offer.SelectTierViewModel +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningDestination +import com.hedvig.android.feature.purchase.apartment.ui.sign.SigningViewModel +import com.hedvig.android.feature.purchase.apartment.ui.success.PurchaseSuccessDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryDestination +import com.hedvig.android.feature.purchase.apartment.ui.summary.PurchaseSummaryViewModel +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.typedPopBackStack +import com.hedvig.android.navigation.compose.typedPopUpTo +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +fun NavGraphBuilder.apartmentPurchaseNavGraph( + navController: NavController, + popBackStack: () -> Unit, + finishApp: () -> Unit, + crossSellAfterFlowRepository: CrossSellAfterFlowRepository, +) { + navgraph( + startDestination = Form::class, + ) { + navdestination { backStackEntry -> + val graphRoute = navController + .getRouteFromBackStack(backStackEntry) + val viewModel: ApartmentFormViewModel = koinViewModel { + parametersOf(graphRoute.productName) + } + ApartmentFormDestination( + 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)) }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + 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 }) + } + }, + navigateToFailure = dropUnlessResumed { navController.navigate(Failure) }, + ) + } + + navdestination { + PurchaseFailureDestination( + onRetry = dropUnlessResumed { navController.popBackStack() }, + close = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) finishApp() + }, + ) + } + } + + navdestination { backStackEntry -> + val route = backStackEntry.toRoute() + PurchaseSuccessDestination( + startDate = route.startDate, + close = dropUnlessResumed { + if (!navController.typedPopBackStack(inclusive = true)) finishApp() + }, + ) + } +} From 9594fb77c5ce8398f82adfa3ab24ac5c1f87823b Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 14:59:11 +0200 Subject: [PATCH 09/22] feat: integrate apartment purchase flow into main app navigation Wire the new apartment purchase feature into the app by adding its nav graph to HedvigNavHost, DI module to ApplicationModule, and navigation callback to InsuranceGraph. Also handle the new CrossSellInfoType.Purchase in the cross-sell sheet's exhaustive when expression. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/app/build.gradle.kts | 1 + .../hedvig/android/app/di/ApplicationModule.kt | 2 ++ .../android/app/navigation/HedvigNavHost.kt | 15 +++++++++++++++ .../cross/sell/sheet/CrossSellSheetViewModel.kt | 1 + .../insurances/navigation/InsuranceGraph.kt | 2 ++ 5 files changed, 21 insertions(+) diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 4047592715..c754732dc1 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -183,6 +183,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.designSystemInternals) implementation(projects.featureAddonPurchase) + implementation(projects.featurePurchaseApartment) implementation(projects.featureChat) implementation(projects.featureChooseTier) implementation(projects.featureClaimChat) 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 24d0169d18..db0104ab6a 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 @@ -63,6 +63,7 @@ import com.hedvig.android.datadog.core.di.datadogModule import com.hedvig.android.datadog.demo.tracking.di.datadogDemoTrackingModule import com.hedvig.android.design.system.hedvig.pdfrenderer.PdfDecoder import com.hedvig.android.feature.addon.purchase.di.addonPurchaseModule +import com.hedvig.android.feature.purchase.apartment.di.apartmentPurchaseModule import com.hedvig.android.feature.change.tier.di.chooseTierModule import com.hedvig.android.feature.chat.di.chatModule import com.hedvig.android.feature.claim.details.di.claimDetailsModule @@ -290,6 +291,7 @@ val applicationModule = module { listOf( addonPurchaseModule, addonRemovalModule, + apartmentPurchaseModule, androidPermissionModule, apolloAuthListenersModule, appModule, 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 886e28e1ed..215bd380ea 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 @@ -79,8 +79,12 @@ import com.hedvig.android.navigation.common.Destination import com.hedvig.android.navigation.compose.typedPopBackStack import com.hedvig.android.navigation.compose.typedPopUpTo import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import org.koin.mp.KoinPlatform import com.hedvig.feature.claim.chat.ClaimChatDestination import com.hedvig.feature.claim.chat.claimChatGraph +import com.hedvig.android.data.cross.sell.after.flow.CrossSellAfterFlowRepository +import com.hedvig.android.feature.purchase.apartment.navigation.ApartmentPurchaseGraphDestination +import com.hedvig.android.feature.purchase.apartment.navigation.apartmentPurchaseNavGraph import com.hedvig.feature.remove.addons.AddonRemoveGraphDestination import com.hedvig.feature.remove.addons.removeAddonsNavGraph @@ -135,6 +139,8 @@ internal fun HedvigNavHost( hedvigAppState.navController.navigate(ImageViewer(imageUrl, cacheKey)) } + val crossSellAfterFlowRepository = KoinPlatform.getKoin().get() + NavHost( navController = navController, startDestination = HomeDestination.Graph::class, @@ -318,6 +324,9 @@ internal fun HedvigNavHost( ), ) }, + onNavigateToApartmentPurchase = { productName -> + navController.navigate(ApartmentPurchaseGraphDestination(productName)) + }, ) foreverGraph( hedvigDeepLinkContainer = hedvigDeepLinkContainer, @@ -473,6 +482,12 @@ internal fun HedvigNavHost( tryToDialPhone = externalNavigator::tryToDialPhone, ) imageViewerGraph(navController, imageLoader) + apartmentPurchaseNavGraph( + navController = navController, + popBackStack = popBackStackOrFinish, + finishApp = finishApp, + crossSellAfterFlowRepository = crossSellAfterFlowRepository, + ) removeAddonsNavGraph( navController = hedvigAppState.navController, onNavigateToNewConversation = { diff --git a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt index e80c1ef7f2..7ebda9897c 100644 --- a/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt +++ b/app/feature/feature-cross-sell-sheet/src/main/kotlin/com/hedvig/android/feature/cross/sell/sheet/CrossSellSheetViewModel.kt @@ -118,6 +118,7 @@ internal fun CrossSellInfoType.toCrossSellSource(): CrossSellInput { is CrossSellInfoType.ClosedClaim -> smartCrossSellInput(FlowSource.CLOSED_CLAIM) CrossSellInfoType.EditCoInsured -> smartCrossSellInput(FlowSource.EDIT_COINSURED) CrossSellInfoType.MovingFlow -> smartCrossSellInput(FlowSource.MOVING) + CrossSellInfoType.Purchase -> smartCrossSellInput(FlowSource.UNKNOWN__) } } 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 3a6dac56ea..5f9a65c2f9 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 @@ -40,6 +40,8 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToAddonPurchaseFlow: (List, AvailableAddon?) -> Unit, onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit, navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit, + // Ready for when backend provides cross-sell type info to distinguish apartment purchase products from web URLs + onNavigateToApartmentPurchase: (productName: String) -> Unit, ) { navgraph( startDestination = InsurancesDestination.Insurances::class, From 3ac8b52074c4bca5ba3111d911f7a9765825b939 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:00:52 +0200 Subject: [PATCH 10/22] chore: fix ktlint formatting in apartment purchase module Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/failure/PurchaseFailureDestination.kt | 5 +-- .../ui/form/ApartmentFormDestination.kt | 2 ++ .../ui/form/ApartmentFormViewModel.kt | 16 +++++++-- .../ui/offer/SelectTierDestination.kt | 6 +--- .../apartment/ui/offer/SelectTierViewModel.kt | 34 ++++++++++++------- .../apartment/ui/sign/SigningDestination.kt | 6 +++- .../apartment/ui/sign/SigningViewModel.kt | 9 ++++- .../ui/success/PurchaseSuccessDestination.kt | 5 +-- .../ui/summary/PurchaseSummaryViewModel.kt | 6 +++- 9 files changed, 57 insertions(+), 32 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt index 0af14e7fa3..51ac25c714 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt @@ -7,10 +7,7 @@ import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.TopAppBarActionType @Composable -internal fun PurchaseFailureDestination( - onRetry: () -> Unit, - close: () -> Unit, -) { +internal fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) { HedvigScaffold( navigateUp = close, topAppBarActionType = TopAppBarActionType.CLOSE, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index 032b2250cd..0d7daac68d 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -49,9 +49,11 @@ internal fun ApartmentFormDestination( ) { when { uiState.isLoadingSession -> HedvigFullScreenCenterAlignedProgress() + uiState.loadSessionError -> HedvigErrorSection( onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, ) + else -> ApartmentFormContent( uiState = uiState, onStreetChanged = { viewModel.emit(ApartmentFormEvent.UpdateStreet(it)) }, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt index 90c74d899d..a18d54885b 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt @@ -26,11 +26,17 @@ internal class ApartmentFormViewModel( internal sealed interface ApartmentFormEvent { data class UpdateStreet(val value: String) : ApartmentFormEvent + data class UpdateZipCode(val value: String) : ApartmentFormEvent + data class UpdateLivingSpace(val value: String) : ApartmentFormEvent + data class UpdateNumberCoInsured(val value: Int) : ApartmentFormEvent + data object Submit : ApartmentFormEvent + data object ClearNavigation : ApartmentFormEvent + data object Retry : ApartmentFormEvent } @@ -60,9 +66,7 @@ private class ApartmentFormPresenter( private val submitFormAndGetOffersUseCase: SubmitFormAndGetOffersUseCase, ) : MoleculePresenter { @Composable - override fun MoleculePresenterScope.present( - lastState: ApartmentFormState, - ): ApartmentFormState { + override fun MoleculePresenterScope.present(lastState: ApartmentFormState): ApartmentFormState { var currentState by remember { mutableStateOf(lastState) } var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } var sessionLoadIteration by remember { mutableIntStateOf(0) } @@ -74,15 +78,19 @@ private class ApartmentFormPresenter( is ApartmentFormEvent.UpdateStreet -> { currentState = currentState.copy(street = event.value, streetError = null) } + is ApartmentFormEvent.UpdateZipCode -> { currentState = currentState.copy(zipCode = event.value, zipCodeError = null) } + is ApartmentFormEvent.UpdateLivingSpace -> { currentState = currentState.copy(livingSpace = event.value, livingSpaceError = null) } + is ApartmentFormEvent.UpdateNumberCoInsured -> { currentState = currentState.copy(numberCoInsured = event.value.coerceAtLeast(0)) } + ApartmentFormEvent.Submit -> { val errors = validate(currentState) if (errors.hasErrors()) { @@ -96,9 +104,11 @@ private class ApartmentFormPresenter( submitIteration++ } } + ApartmentFormEvent.ClearNavigation -> { currentState = currentState.copy(offersToNavigate = null) } + ApartmentFormEvent.Retry -> { if (sessionAndIntent == null) { currentState = currentState.copy(loadSessionError = false, isLoadingSession = true) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index dde8f12142..46dffd5d4b 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -122,11 +122,7 @@ private fun SelectTierScreen( } @Composable -private fun TierCard( - offer: TierOfferData, - isSelected: Boolean, - onSelect: () -> Unit, -) { +private fun TierCard(offer: TierOfferData, isSelected: Boolean, onSelect: () -> Unit) { HedvigCard( onClick = onSelect, color = if (isSelected) { diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt index e28fc6d485..09c2e49193 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt @@ -14,17 +14,17 @@ import com.hedvig.android.molecule.public.MoleculeViewModel internal class SelectTierViewModel( params: SelectTierParameters, ) : MoleculeViewModel( - SelectTierUiState( - offers = params.offers, - selectedOfferId = params.offers.firstOrNull { it.tierDisplayName == "Standard" }?.offerId - ?: params.offers.firstOrNull()?.offerId - ?: "", - shopSessionId = params.shopSessionId, - productDisplayName = params.productDisplayName, - summaryToNavigate = null, - ), - SelectTierPresenter(params), -) + SelectTierUiState( + offers = params.offers, + selectedOfferId = params.offers.firstOrNull { it.tierDisplayName == "Standard" }?.offerId + ?: params.offers.firstOrNull()?.offerId + ?: "", + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = null, + ), + SelectTierPresenter(params), + ) internal class SelectTierPresenter( private val params: SelectTierParameters, @@ -36,7 +36,10 @@ internal class SelectTierPresenter( CollectEvents { event -> when (event) { - is SelectTierEvent.SelectOffer -> selectedOfferId = event.offerId + is SelectTierEvent.SelectOffer -> { + selectedOfferId = event.offerId + } + SelectTierEvent.Continue -> { val selectedOffer = params.offers.first { it.offerId == selectedOfferId } summaryToNavigate = SummaryParameters( @@ -45,7 +48,10 @@ internal class SelectTierPresenter( productDisplayName = params.productDisplayName, ) } - SelectTierEvent.ClearNavigation -> summaryToNavigate = null + + SelectTierEvent.ClearNavigation -> { + summaryToNavigate = null + } } } @@ -69,6 +75,8 @@ internal data class SelectTierUiState( internal sealed interface SelectTierEvent { data class SelectOffer(val offerId: String) : SelectTierEvent + data object Continue : SelectTierEvent + data object ClearNavigation : SelectTierEvent } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt index 11db658457..af885f753a 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -26,13 +26,17 @@ internal fun SigningDestination( } when (val state = uiState) { - is SigningUiState.Polling -> HedvigFullScreenCenterAlignedProgress() + is SigningUiState.Polling -> { + HedvigFullScreenCenterAlignedProgress() + } + is SigningUiState.Success -> { LaunchedEffect(state) { navigateToSuccess(state.startDate) } HedvigFullScreenCenterAlignedProgress() } + is SigningUiState.Failed -> { LaunchedEffect(state) { navigateToFailure() diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt index 8987f68921..de92711b7e 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -40,7 +40,10 @@ internal class SigningPresenter( CollectEvents { event -> when (event) { - SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.BankIdOpened -> { + bankIdOpened = true + } + SigningEvent.ClearNavigation -> { // Reset state if needed } @@ -61,10 +64,12 @@ internal class SigningPresenter( currentState = SigningUiState.Success(startDate = signingParameters.startDate) return@LaunchedEffect } + SigningStatus.FAILED -> { currentState = SigningUiState.Failed return@LaunchedEffect } + SigningStatus.PENDING -> { /* continue polling */ } } }, @@ -87,10 +92,12 @@ internal sealed interface SigningUiState { ) : SigningUiState data class Success(val startDate: String?) : SigningUiState + data object Failed : SigningUiState } internal sealed interface SigningEvent { data object BankIdOpened : SigningEvent + data object ClearNavigation : SigningEvent } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt index b1ccb653ed..980b287ddd 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt @@ -17,10 +17,7 @@ import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.TopAppBarActionType @Composable -internal fun PurchaseSuccessDestination( - startDate: String?, - close: () -> Unit, -) { +internal fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { HedvigScaffold( navigateUp = close, topAppBarActionType = TopAppBarActionType.CLOSE, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt index 1f3383edcf..02348fb2af 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryViewModel.kt @@ -45,7 +45,10 @@ internal class PurchaseSummaryPresenter( CollectEvents { event -> when (event) { - PurchaseSummaryEvent.Confirm -> confirmIteration++ + PurchaseSummaryEvent.Confirm -> { + confirmIteration++ + } + PurchaseSummaryEvent.ClearNavigation -> { signingToNavigate = null navigateToFailure = false @@ -94,5 +97,6 @@ internal data class PurchaseSummaryUiState( internal sealed interface PurchaseSummaryEvent { data object Confirm : PurchaseSummaryEvent + data object ClearNavigation : PurchaseSummaryEvent } From 329f545cbc88927c5203805bb2366037ed977495 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:08:37 +0200 Subject: [PATCH 11/22] feat: route apartment cross-sells to in-app purchase flow Parses the storeUrl to detect apartment products (hyresratt/bostadsratt in the URL path) and routes them to the native purchase flow instead of opening the browser. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../insurances/navigation/InsuranceGraph.kt | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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 5f9a65c2f9..14fc7b800f 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 @@ -20,6 +20,7 @@ import com.hedvig.android.navigation.compose.navDeepLinks import com.hedvig.android.navigation.compose.navdestination import com.hedvig.android.navigation.compose.navgraph import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import java.net.URLDecoder import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -40,7 +41,6 @@ fun NavGraphBuilder.insuranceGraph( onNavigateToAddonPurchaseFlow: (List, AvailableAddon?) -> Unit, onNavigateToRemoveAddon: (ContractId?, AddonVariant?) -> Unit, navigateToUpgradeAddon: (ContractId?, AddonVariant?) -> Unit, - // Ready for when backend provides cross-sell type info to distinguish apartment purchase products from web URLs onNavigateToApartmentPurchase: (productName: String) -> Unit, ) { navgraph( @@ -61,7 +61,14 @@ fun NavGraphBuilder.insuranceGraph( onInsuranceCardClick = dropUnlessResumed { contractId: String -> navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId)) }, - onCrossSellClick = dropUnlessResumed { url: String -> openUrl(url) }, + onCrossSellClick = dropUnlessResumed { url: String -> + val productName = parseApartmentProductFromUrl(url) + if (productName != null) { + onNavigateToApartmentPurchase(productName) + } else { + openUrl(url) + } + }, navigateToCancelledInsurances = dropUnlessResumed { navController.navigate(InsurancesDestinations.TerminatedInsurances) }, @@ -119,3 +126,17 @@ fun NavGraphBuilder.insuranceGraph( } } } + +private fun parseApartmentProductFromUrl(url: String): String? { + val decodedUrl = try { + URLDecoder.decode(url, "UTF-8") + } catch (_: Exception) { + url + } + val lowerUrl = decodedUrl.lowercase() + return when { + lowerUrl.contains("hyresratt") || lowerUrl.contains("home-insurance/rental") -> "SE_APARTMENT_RENT" + lowerUrl.contains("bostadsratt") || lowerUrl.contains("home-insurance/homeowner") -> "SE_APARTMENT_BRF" + else -> null + } +} From 121fc12bda8b5029d26b226e2213a38e1d2f5536 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:17:48 +0200 Subject: [PATCH 12/22] fix: remove nested verticalScroll causing crash in form screen HedvigScaffold already provides a scrollable Column, so adding verticalScroll inside it caused infinite height constraints. Also hardcode all cross-sell clicks to route to in-app purchase for testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../feature/insurances/navigation/InsuranceGraph.kt | 8 ++------ .../apartment/ui/form/ApartmentFormDestination.kt | 6 +----- 2 files changed, 3 insertions(+), 11 deletions(-) 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 14fc7b800f..f7902e706a 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 @@ -62,12 +62,8 @@ fun NavGraphBuilder.insuranceGraph( navController.navigate(InsurancesDestinations.InsuranceContractDetail(contractId)) }, onCrossSellClick = dropUnlessResumed { url: String -> - val productName = parseApartmentProductFromUrl(url) - if (productName != null) { - onNavigateToApartmentPurchase(productName) - } else { - openUrl(url) - } + // Hardcoded for testing: route all cross-sells to in-app purchase + onNavigateToApartmentPurchase("SE_APARTMENT_RENT") }, navigateToCancelledInsurances = dropUnlessResumed { navController.navigate(InsurancesDestinations.TerminatedInsurances) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index 0d7daac68d..b5a8e5cb40 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @@ -84,9 +82,7 @@ private fun ApartmentFormContent( return } Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), ) { Spacer(Modifier.height(16.dp)) Column( From b81e6adb34d2dd0beff6179705ef487bacc8ea34 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:21:04 +0200 Subject: [PATCH 13/22] fix: rewrite tier selection to use HedvigScaffold instead of manual scroll Removes nested verticalScroll + weight(1f) that caused infinite height crash. Uses HedvigScaffold which handles scrolling internally. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/offer/SelectTierDestination.kt | 115 +++++++----------- 1 file changed, 41 insertions(+), 74 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index 46dffd5d4b..cef2810b11 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -7,17 +7,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -27,11 +21,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.TopAppBarWithBack import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters @@ -53,88 +46,62 @@ internal fun SelectTierDestination( onContinueToSummary(uiState.summaryToNavigate) } } - SelectTierScreen( - uiState = uiState, + HedvigScaffold( navigateUp = navigateUp, - onSelectOffer = { viewModel.emit(SelectTierEvent.SelectOffer(it)) }, - onContinue = { viewModel.emit(SelectTierEvent.Continue) }, - ) -} - -@Composable -private fun SelectTierScreen( - uiState: SelectTierUiState, - navigateUp: () -> Unit, - onSelectOffer: (String) -> Unit, - onContinue: () -> Unit, -) { - Surface( - color = HedvigTheme.colorScheme.backgroundPrimary, - modifier = Modifier.fillMaxSize(), ) { - Column { - TopAppBarWithBack( - title = "", - onClick = navigateUp, + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Anpassa din f\u00f6rs\u00e4kring", + style = HedvigTheme.typography.headlineMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(4.dp)) + HedvigText( + text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(24.dp)) + for ((index, offer) in uiState.offers.withIndex()) { + TierCard( + offer = offer, + isSelected = offer.offerId == uiState.selectedOfferId, + onSelect = { viewModel.emit(SelectTierEvent.SelectOffer(offer.offerId)) }, + modifier = Modifier.padding(horizontal = 16.dp), ) - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - ) { - Spacer(Modifier.height(16.dp)) - HedvigText( - text = "Anpassa din f\u00f6rs\u00e4kring", - style = HedvigTheme.typography.headlineMedium, - ) - Spacer(Modifier.height(4.dp)) - HedvigText( - text = "V\u00e4lj den skyddsniv\u00e5 som passar dig b\u00e4st", - style = HedvigTheme.typography.bodyMedium, - color = HedvigTheme.colorScheme.textSecondary, - ) - Spacer(Modifier.height(24.dp)) - for ((index, offer) in uiState.offers.withIndex()) { - val isSelected = offer.offerId == uiState.selectedOfferId - TierCard( - offer = offer, - isSelected = isSelected, - onSelect = { onSelectOffer(offer.offerId) }, - ) - if (index < uiState.offers.lastIndex) { - Spacer(Modifier.height(12.dp)) - } - } - Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(24.dp)) - HedvigButton( - text = "Forts\u00e4tt", - onClick = dropUnlessResumed { onContinue() }, - enabled = uiState.offers.any { it.offerId == uiState.selectedOfferId }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (index < uiState.offers.lastIndex) { + Spacer(Modifier.height(12.dp)) } } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Forts\u00e4tt", + onClick = dropUnlessResumed { viewModel.emit(SelectTierEvent.Continue) }, + enabled = uiState.offers.any { it.offerId == uiState.selectedOfferId }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) } } @Composable -private fun TierCard(offer: TierOfferData, isSelected: Boolean, onSelect: () -> Unit) { +private fun TierCard( + offer: TierOfferData, + isSelected: Boolean, + onSelect: () -> Unit, + modifier: Modifier = Modifier, +) { HedvigCard( onClick = onSelect, - color = if (isSelected) { - HedvigTheme.colorScheme.surfacePrimary - } else { - HedvigTheme.colorScheme.surfacePrimary - }, borderColor = if (isSelected) { HedvigTheme.colorScheme.signalGreenElement } else { HedvigTheme.colorScheme.borderSecondary }, + modifier = modifier, ) { Column(Modifier.padding(16.dp)) { Row( From fb2620507939b15b99e2c50021676f2a9dcc8973 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:24:13 +0200 Subject: [PATCH 14/22] feat: add Compose previews to all purchase apartment screens Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/failure/PurchaseFailureDestination.kt | 10 +++ .../ui/form/ApartmentFormDestination.kt | 69 ++++++++++++++++ .../ui/offer/SelectTierDestination.kt | 79 ++++++++++++++++++- .../ui/success/PurchaseSuccessDestination.kt | 9 +++ .../ui/summary/PurchaseSummaryDestination.kt | 34 ++++++++ 5 files changed, 199 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt index 51ac25c714..a09ae6b6a2 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/failure/PurchaseFailureDestination.kt @@ -3,7 +3,9 @@ package com.hedvig.android.feature.purchase.apartment.ui.failure import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.TopAppBarActionType @Composable @@ -18,3 +20,11 @@ internal fun PurchaseFailureDestination(onRetry: () -> Unit, close: () -> Unit) ) } } + +@HedvigPreview +@Composable +private fun PreviewPurchaseFailure() { + HedvigTheme { + PurchaseFailureDestination(onRetry = {}, close = {}) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index b5a8e5cb40..d39eb3b72e 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -17,6 +17,9 @@ 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.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress import com.hedvig.android.design.system.hedvig.HedvigScaffold @@ -165,3 +168,69 @@ private fun String?.toErrorState(): HedvigTextFieldDefaults.ErrorState { HedvigTextFieldDefaults.ErrorState.NoError } } + +@HedvigPreview +@Composable +private fun PreviewApartmentFormEmpty() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + uiState = ApartmentFormState(), + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + onRetry = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewApartmentFormFilled() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + uiState = ApartmentFormState( + street = "Storgatan 1", + zipCode = "12345", + livingSpace = "65", + numberCoInsured = 1, + ), + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + onRetry = {}, + ) + } + } +} + +@HedvigPreview +@Composable +private fun PreviewApartmentFormWithErrors() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ApartmentFormContent( + uiState = ApartmentFormState( + street = "", + zipCode = "123", + livingSpace = "", + streetError = "Ange en adress", + zipCodeError = "Ange ett giltigt postnummer (5 siffror)", + livingSpaceError = "Ange boyta i kvadratmeter", + ), + onStreetChanged = {}, + onZipCodeChanged = {}, + onLivingSpaceChanged = {}, + onNumberCoInsuredChanged = {}, + onSubmit = {}, + onRetry = {}, + ) + } + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index cef2810b11..8258fb1e82 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme @@ -46,6 +47,21 @@ internal fun SelectTierDestination( onContinueToSummary(uiState.summaryToNavigate) } } + SelectTierContent( + uiState = uiState, + navigateUp = navigateUp, + onSelectOffer = { viewModel.emit(SelectTierEvent.SelectOffer(it)) }, + onContinue = { viewModel.emit(SelectTierEvent.Continue) }, + ) +} + +@Composable +private fun SelectTierContent( + uiState: SelectTierUiState, + navigateUp: () -> Unit = {}, + onSelectOffer: (String) -> Unit = {}, + onContinue: () -> Unit = {}, +) { HedvigScaffold( navigateUp = navigateUp, ) { @@ -67,7 +83,7 @@ internal fun SelectTierDestination( TierCard( offer = offer, isSelected = offer.offerId == uiState.selectedOfferId, - onSelect = { viewModel.emit(SelectTierEvent.SelectOffer(offer.offerId)) }, + onSelect = { onSelectOffer(offer.offerId) }, modifier = Modifier.padding(horizontal = 16.dp), ) if (index < uiState.offers.lastIndex) { @@ -77,7 +93,7 @@ internal fun SelectTierDestination( Spacer(Modifier.height(24.dp)) HedvigButton( text = "Forts\u00e4tt", - onClick = dropUnlessResumed { viewModel.emit(SelectTierEvent.Continue) }, + onClick = dropUnlessResumed { onContinue() }, enabled = uiState.offers.any { it.offerId == uiState.selectedOfferId }, modifier = Modifier .fillMaxWidth() @@ -172,3 +188,62 @@ private fun formatPrice(amount: Double, currencyCode: String): String { format.maximumFractionDigits = 0 return "${format.format(amount)}/m\u00e5n" } + +private val previewOffers = listOf( + TierOfferData( + offerId = "1", + tierDisplayName = "Hem Max", + tierDescription = "Vårt mest omfattande skydd", + grossAmount = 189.0, + grossCurrencyCode = "SEK", + netAmount = 189.0, + netCurrencyCode = "SEK", + usps = listOf("Försäkringsbelopp 1 000 000 kr", "Drulle upp till 50 000 kr ingår", "ID-skydd och flyttskydd"), + exposureDisplayName = "Storgatan 1", + deductibleDisplayName = "1 500 kr", + hasDiscount = false, + ), + TierOfferData( + offerId = "2", + tierDisplayName = "Hem Standard", + tierDescription = "Vår mest populära försäkring", + grossAmount = 139.0, + grossCurrencyCode = "SEK", + netAmount = 118.0, + netCurrencyCode = "SEK", + usps = listOf("Försäkringsbelopp 1 000 000 kr", "Drulle upp till 50 000 kr ingår"), + exposureDisplayName = "Storgatan 1", + deductibleDisplayName = "1 500 kr", + hasDiscount = true, + ), + TierOfferData( + offerId = "3", + tierDisplayName = "Hem Bas", + tierDescription = "Innehåller vårt grundskydd", + grossAmount = 99.0, + grossCurrencyCode = "SEK", + netAmount = 99.0, + netCurrencyCode = "SEK", + usps = listOf("Grundskydd"), + exposureDisplayName = "Storgatan 1", + deductibleDisplayName = "1 500 kr", + hasDiscount = false, + ), +) + +@HedvigPreview +@Composable +private fun PreviewSelectTierStandard() { + HedvigTheme { + SelectTierContent( + uiState = SelectTierUiState( + offers = previewOffers, + selectedOfferId = "2", + shopSessionId = "session", + productDisplayName = "Hemförsäkring Hyresrätt", + ), + onSelectOffer = {}, + onContinue = {}, + ) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt index 980b287ddd..8edeb135ab 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/success/PurchaseSuccessDestination.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme @@ -50,3 +51,11 @@ internal fun PurchaseSuccessDestination(startDate: String?, close: () -> Unit) { Spacer(Modifier.height(16.dp)) } } + +@HedvigPreview +@Composable +private fun PreviewPurchaseSuccess() { + HedvigTheme { + PurchaseSuccessDestination(startDate = "2026-05-01", close = {}) + } +} diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt index c2c4618d7e..4ae1728c4d 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt @@ -19,8 +19,11 @@ import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters +import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData @Composable internal fun PurchaseSummaryDestination( @@ -109,3 +112,34 @@ private fun PurchaseSummaryScreen( Spacer(Modifier.height(16.dp)) } } + +@HedvigPreview +@Composable +private fun PreviewPurchaseSummary() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + PurchaseSummaryScreen( + params = SummaryParameters( + shopSessionId = "session", + selectedOffer = TierOfferData( + offerId = "1", + tierDisplayName = "Hem Standard", + tierDescription = "Vår mest populära försäkring", + grossAmount = 139.0, + grossCurrencyCode = "SEK", + netAmount = 118.0, + netCurrencyCode = "SEK", + usps = emptyList(), + exposureDisplayName = "Storgatan 1", + deductibleDisplayName = "1 500 kr", + hasDiscount = true, + ), + productDisplayName = "Hemförsäkring Hyresrätt", + ), + isSubmitting = false, + navigateUp = {}, + onConfirm = {}, + ) + } + } +} From 4fa6fa2aef3b4d027305373dc0274c2fca69eca9 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:25:57 +0200 Subject: [PATCH 15/22] fix: move text field state to UI layer to prevent cursor jumping Text state was managed in the Molecule presenter via events, causing a round-trip delay on each keystroke that produced cursor jumping and text disappearing. Now text fields use local remember { mutableStateOf } in the composable and only send values to the presenter on submit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ui/form/ApartmentFormDestination.kt | 158 +++++++++++------- .../ui/form/ApartmentFormViewModel.kt | 74 ++++---- 2 files changed, 122 insertions(+), 110 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index d39eb3b72e..135d03694c 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -11,23 +11,28 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight 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.HedvigButton -import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Surface 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.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme 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.apartment.data.ApartmentOffers @Composable @@ -55,22 +60,54 @@ internal fun ApartmentFormDestination( onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, ) - else -> ApartmentFormContent( - uiState = uiState, - onStreetChanged = { viewModel.emit(ApartmentFormEvent.UpdateStreet(it)) }, - onZipCodeChanged = { viewModel.emit(ApartmentFormEvent.UpdateZipCode(it)) }, - onLivingSpaceChanged = { viewModel.emit(ApartmentFormEvent.UpdateLivingSpace(it)) }, - onNumberCoInsuredChanged = { viewModel.emit(ApartmentFormEvent.UpdateNumberCoInsured(it)) }, - onSubmit = { viewModel.emit(ApartmentFormEvent.Submit) }, - onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, - ) + else -> { + var street by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var livingSpace by remember { mutableStateOf("") } + var numberCoInsured by remember { mutableIntStateOf(0) } + + ApartmentFormContent( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + streetError = uiState.streetError, + zipCodeError = uiState.zipCodeError, + livingSpaceError = uiState.livingSpaceError, + isSubmitting = uiState.isSubmitting, + onStreetChanged = { street = it }, + onZipCodeChanged = { value -> if (value.all { it.isDigit() }) zipCode = value }, + onLivingSpaceChanged = { value -> + if (value.isEmpty() || value.toIntOrNull() != null) livingSpace = value + }, + onNumberCoInsuredChanged = { numberCoInsured = it }, + onSubmit = { + viewModel.emit( + ApartmentFormEvent.SubmitForm( + street = street, + zipCode = zipCode, + livingSpace = livingSpace, + numberCoInsured = numberCoInsured, + ), + ) + }, + onRetry = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + } } } } @Composable private fun ApartmentFormContent( - uiState: ApartmentFormState, + street: String, + zipCode: String, + livingSpace: String, + numberCoInsured: Int, + streetError: String?, + zipCodeError: String?, + livingSpaceError: String?, + isSubmitting: Boolean, onStreetChanged: (String) -> Unit, onZipCodeChanged: (String) -> Unit, onLivingSpaceChanged: (String) -> Unit, @@ -78,12 +115,6 @@ private fun ApartmentFormContent( onSubmit: () -> Unit, onRetry: () -> Unit, ) { - if (uiState.submitError != null) { - HedvigErrorSection( - onButtonClick = onRetry, - ) - return - } Column( modifier = Modifier.fillMaxWidth(), ) { @@ -93,67 +124,57 @@ private fun ApartmentFormContent( verticalArrangement = Arrangement.spacedBy(4.dp), ) { HedvigTextField( - text = uiState.street, + text = street, onValueChange = onStreetChanged, labelText = "Adress", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = uiState.streetError.toErrorState(), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next, - ), - enabled = !uiState.isSubmitting, + errorState = streetError.toErrorState(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + enabled = !isSubmitting, ) HedvigTextField( - text = uiState.zipCode, - onValueChange = { value -> - if (value.all { it.isDigit() }) { - onZipCodeChanged(value) - } - }, + text = zipCode, + onValueChange = onZipCodeChanged, labelText = "Postnummer", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = uiState.zipCodeError.toErrorState(), + errorState = zipCodeError.toErrorState(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Next, ), - enabled = !uiState.isSubmitting, + enabled = !isSubmitting, ) HedvigTextField( - text = uiState.livingSpace, - onValueChange = { value -> - if (value.isEmpty() || value.toIntOrNull() != null) { - onLivingSpaceChanged(value) - } - }, + text = livingSpace, + onValueChange = onLivingSpaceChanged, labelText = "Boyta (kvm)", textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - errorState = uiState.livingSpaceError.toErrorState(), + errorState = livingSpaceError.toErrorState(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done, ), - enabled = !uiState.isSubmitting, + enabled = !isSubmitting, ) HedvigStepper( - text = when (uiState.numberCoInsured) { + text = when (numberCoInsured) { 0 -> "Bara du" - else -> "Du + ${uiState.numberCoInsured}" + else -> "Du + $numberCoInsured" }, stepperSize = Medium, stepperStyle = Labeled("Antal medförsäkrade"), - onMinusClick = { onNumberCoInsuredChanged(uiState.numberCoInsured - 1) }, - onPlusClick = { onNumberCoInsuredChanged(uiState.numberCoInsured + 1) }, - isPlusEnabled = !uiState.isSubmitting && uiState.numberCoInsured < 5, - isMinusEnabled = !uiState.isSubmitting && uiState.numberCoInsured > 0, + onMinusClick = { onNumberCoInsuredChanged(numberCoInsured - 1) }, + onPlusClick = { onNumberCoInsuredChanged(numberCoInsured + 1) }, + isPlusEnabled = !isSubmitting && numberCoInsured < 5, + isMinusEnabled = !isSubmitting && numberCoInsured > 0, ) } Spacer(Modifier.height(16.dp)) HedvigButton( text = "Beräkna pris", onClick = onSubmit, - enabled = !uiState.isSubmitting, - isLoading = uiState.isSubmitting, + enabled = !isSubmitting, + isLoading = isSubmitting, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) @@ -175,7 +196,14 @@ private fun PreviewApartmentFormEmpty() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ApartmentFormContent( - uiState = ApartmentFormState(), + street = "", + zipCode = "", + livingSpace = "", + numberCoInsured = 0, + streetError = null, + zipCodeError = null, + livingSpaceError = null, + isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, @@ -193,12 +221,14 @@ private fun PreviewApartmentFormFilled() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ApartmentFormContent( - uiState = ApartmentFormState( - street = "Storgatan 1", - zipCode = "12345", - livingSpace = "65", - numberCoInsured = 1, - ), + street = "Storgatan 1", + zipCode = "12345", + livingSpace = "65", + numberCoInsured = 1, + streetError = null, + zipCodeError = null, + livingSpaceError = null, + isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, @@ -216,14 +246,14 @@ private fun PreviewApartmentFormWithErrors() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ApartmentFormContent( - uiState = ApartmentFormState( - street = "", - zipCode = "123", - livingSpace = "", - streetError = "Ange en adress", - zipCodeError = "Ange ett giltigt postnummer (5 siffror)", - livingSpaceError = "Ange boyta i kvadratmeter", - ), + street = "", + zipCode = "123", + livingSpace = "", + numberCoInsured = 0, + streetError = "Ange en adress", + zipCodeError = "Ange ett giltigt postnummer (5 siffror)", + livingSpaceError = "Ange boyta i kvadratmeter", + isSubmitting = false, onStreetChanged = {}, onZipCodeChanged = {}, onLivingSpaceChanged = {}, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt index a18d54885b..02c5a6e1da 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormViewModel.kt @@ -25,15 +25,12 @@ internal class ApartmentFormViewModel( ) internal sealed interface ApartmentFormEvent { - data class UpdateStreet(val value: String) : ApartmentFormEvent - - data class UpdateZipCode(val value: String) : ApartmentFormEvent - - data class UpdateLivingSpace(val value: String) : ApartmentFormEvent - - data class UpdateNumberCoInsured(val value: Int) : ApartmentFormEvent - - data object Submit : ApartmentFormEvent + data class SubmitForm( + val street: String, + val zipCode: String, + val livingSpace: String, + val numberCoInsured: Int, + ) : ApartmentFormEvent data object ClearNavigation : ApartmentFormEvent @@ -41,10 +38,6 @@ internal sealed interface ApartmentFormEvent { } internal data class ApartmentFormState( - val street: String = "", - val zipCode: String = "", - val livingSpace: String = "", - val numberCoInsured: Int = 0, val streetError: String? = null, val zipCodeError: String? = null, val livingSpaceError: String? = null, @@ -71,28 +64,12 @@ private class ApartmentFormPresenter( var sessionAndIntent: SessionAndIntent? by remember { mutableStateOf(null) } var sessionLoadIteration by remember { mutableIntStateOf(0) } var submitIteration by remember { mutableIntStateOf(0) } - var shouldSubmit by remember { mutableStateOf(false) } + var pendingSubmit: ApartmentFormEvent.SubmitForm? by remember { mutableStateOf(null) } CollectEvents { event -> when (event) { - is ApartmentFormEvent.UpdateStreet -> { - currentState = currentState.copy(street = event.value, streetError = null) - } - - is ApartmentFormEvent.UpdateZipCode -> { - currentState = currentState.copy(zipCode = event.value, zipCodeError = null) - } - - is ApartmentFormEvent.UpdateLivingSpace -> { - currentState = currentState.copy(livingSpace = event.value, livingSpaceError = null) - } - - is ApartmentFormEvent.UpdateNumberCoInsured -> { - currentState = currentState.copy(numberCoInsured = event.value.coerceAtLeast(0)) - } - - ApartmentFormEvent.Submit -> { - val errors = validate(currentState) + is ApartmentFormEvent.SubmitForm -> { + val errors = validate(event.street, event.zipCode, event.livingSpace) if (errors.hasErrors()) { currentState = currentState.copy( streetError = errors.streetError, @@ -100,7 +77,12 @@ private class ApartmentFormPresenter( livingSpaceError = errors.livingSpaceError, ) } else { - shouldSubmit = true + currentState = currentState.copy( + streetError = null, + zipCodeError = null, + livingSpaceError = null, + ) + pendingSubmit = event submitIteration++ } } @@ -134,16 +116,16 @@ private class ApartmentFormPresenter( } LaunchedEffect(submitIteration) { - if (!shouldSubmit) return@LaunchedEffect + val submit = pendingSubmit ?: return@LaunchedEffect val session = sessionAndIntent ?: return@LaunchedEffect - shouldSubmit = false + pendingSubmit = null currentState = currentState.copy(isSubmitting = true, submitError = null) submitFormAndGetOffersUseCase.invoke( priceIntentId = session.priceIntentId, - street = currentState.street, - zipCode = currentState.zipCode, - livingSpace = currentState.livingSpace.toInt(), - numberCoInsured = currentState.numberCoInsured, + street = submit.street, + zipCode = submit.zipCode, + livingSpace = submit.livingSpace.toInt(), + numberCoInsured = submit.numberCoInsured, ).fold( ifLeft = { error -> currentState = currentState.copy( @@ -175,18 +157,18 @@ private data class ValidationErrors( fun hasErrors(): Boolean = streetError != null || zipCodeError != null || livingSpaceError != null } -private fun validate(state: ApartmentFormState): ValidationErrors { +private fun validate(street: String, zipCode: String, livingSpace: String): ValidationErrors { return ValidationErrors( - streetError = if (state.street.isBlank()) "Ange en adress" else null, + streetError = if (street.isBlank()) "Ange en adress" else null, zipCodeError = when { - state.zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" - !state.zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" + zipCode.length != 5 -> "Ange ett giltigt postnummer (5 siffror)" + !zipCode.all { it.isDigit() } -> "Postnumret får bara innehålla siffror" else -> null }, livingSpaceError = when { - state.livingSpace.isBlank() -> "Ange boyta" - state.livingSpace.toIntOrNull() == null -> "Ange ett giltigt tal" - state.livingSpace.toInt() <= 0 -> "Boytan måste vara större än 0" + livingSpace.isBlank() -> "Ange boyta" + livingSpace.toIntOrNull() == null -> "Ange ett giltigt tal" + livingSpace.toInt() <= 0 -> "Boytan måste vara större än 0" else -> null }, ) From cc8558078514cc5612798ac120899548fdacb016 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:26:55 +0200 Subject: [PATCH 16/22] fix: use Locale constructor instead of Locale.of (not available on Android) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../purchase/apartment/ui/offer/SelectTierDestination.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index 8258fb1e82..1c8638faed 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -183,7 +183,8 @@ private fun TierCard( } private fun formatPrice(amount: Double, currencyCode: String): String { - val format = NumberFormat.getCurrencyInstance(Locale.of("sv", "SE")) + @Suppress("DEPRECATION") + val format = NumberFormat.getCurrencyInstance(Locale("sv", "SE")) format.currency = Currency.getInstance(currencyCode) format.maximumFractionDigits = 0 return "${format.format(amount)}/m\u00e5n" From fc45cfeb5a98c7d594412d8af495e531009bcb10 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:35:23 +0200 Subject: [PATCH 17/22] fix: add remember to mutableStateOf in SelectTierPresenter Without remember, state was recreated on every recomposition, preventing tier selection and navigation from working. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../purchase/apartment/ui/offer/SelectTierDestination.kt | 1 + .../purchase/apartment/ui/offer/SelectTierViewModel.kt | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index 1c8638faed..5273afad4f 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -242,6 +242,7 @@ private fun PreviewSelectTierStandard() { selectedOfferId = "2", shopSessionId = "session", productDisplayName = "Hemförsäkring Hyresrätt", + summaryToNavigate = null ), onSelectOffer = {}, onContinue = {}, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt index 09c2e49193..cbcac57417 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt @@ -3,6 +3,7 @@ package com.hedvig.android.feature.purchase.apartment.ui.offer import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.feature.purchase.apartment.navigation.SelectTierParameters import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters @@ -31,8 +32,8 @@ internal class SelectTierPresenter( ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { - var selectedOfferId by mutableStateOf(lastState.selectedOfferId) - var summaryToNavigate: SummaryParameters? by mutableStateOf(lastState.summaryToNavigate) + var selectedOfferId by remember { mutableStateOf(lastState.selectedOfferId) } + var summaryToNavigate: SummaryParameters? by remember { mutableStateOf(lastState.summaryToNavigate) } CollectEvents { event -> when (event) { From b3a6ab756ce2e354d1cced0189ec3c3f24a0ed12 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 15:44:44 +0200 Subject: [PATCH 18/22] feat: add BankID QR code fallback when app is not installed Detects if BankID app is installed via PackageManager. If installed, auto-opens the app (existing behavior). If not installed, shows a live QR code that can be scanned from another device. Uses the same ZXing-based QR rendering pattern as the login flow. The liveQrCodeData is already returned by the signing poll query and updates dynamically. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../build.gradle.kts | 1 + .../data/PollSigningStatusUseCase.kt | 12 +- .../apartment/data/PurchaseApartmentModels.kt | 5 + .../apartment/ui/sign/SigningDestination.kt | 159 +++++++++++++++++- .../apartment/ui/sign/SigningViewModel.kt | 29 ++-- 5 files changed, 180 insertions(+), 26 deletions(-) diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index 54964b7db0..ed61c94ec1 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.koin.composeViewModel) implementation(libs.koin.core) implementation(libs.kotlinx.serialization.core) + implementation(libs.zXing) implementation(projects.apolloCore) implementation(projects.apolloOctopusPublic) implementation(projects.composeUi) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt index fac7935460..b38438ed31 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -11,13 +11,13 @@ import octopus.ApartmentShopSessionSigningQuery import octopus.type.ShopSessionSigningStatus internal interface PollSigningStatusUseCase { - suspend fun invoke(signingId: String): Either + suspend fun invoke(signingId: String): Either } internal class PollSigningStatusUseCaseImpl( private val apolloClient: ApolloClient, ) : PollSigningStatusUseCase { - override suspend fun invoke(signingId: String): Either { + override suspend fun invoke(signingId: String): Either { return either { apolloClient .query(ApartmentShopSessionSigningQuery(signingId = signingId)) @@ -28,9 +28,9 @@ internal class PollSigningStatusUseCaseImpl( raise(ErrorMessage()) }, ifRight = { result -> - when (result.shopSessionSigning.status) { + val signing = result.shopSessionSigning + val status = when (signing.status) { ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED - ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED ShopSessionSigningStatus.PENDING, @@ -38,6 +38,10 @@ internal class PollSigningStatusUseCaseImpl( ShopSessionSigningStatus.UNKNOWN__, -> SigningStatus.PENDING } + SigningPollResult( + status = status, + liveQrCodeData = signing.seBankidProperties?.liveQrCodeData, + ) }, ) } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt index 72d42e8d31..cd67040a71 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PurchaseApartmentModels.kt @@ -29,6 +29,11 @@ internal data class SigningStart( val autoStartToken: String, ) +internal data class SigningPollResult( + val status: SigningStatus, + val liveQrCodeData: String?, +) + internal enum class SigningStatus { PENDING, SIGNED, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt index af885f753a..2557c4587f 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -1,13 +1,50 @@ package com.hedvig.android.feature.purchase.apartment.ui.sign +import android.annotation.SuppressLint +import android.content.Context import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap import android.net.Uri +import android.os.Build +import androidx.compose.foundation.Image +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.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable internal fun SigningDestination( @@ -17,17 +54,29 @@ internal fun SigningDestination( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - - LaunchedEffect(Unit) { - val token = (uiState as? SigningUiState.Polling)?.autoStartToken ?: return@LaunchedEffect - val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=$token&redirect=null") - context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) - viewModel.emit(SigningEvent.BankIdOpened) - } + val canOpenBankId = remember { canBankIdAppHandleUri(context) } when (val state = uiState) { is SigningUiState.Polling -> { - HedvigFullScreenCenterAlignedProgress() + if (canOpenBankId && !state.bankIdOpened) { + LaunchedEffect(Unit) { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + } + HedvigFullScreenCenterAlignedProgress() + } else if (!canOpenBankId) { + QrCodeSigningScreen( + liveQrCodeData = state.liveQrCodeData, + onOpenBankId = { + val bankIdUri = Uri.parse("https://app.bankid.com/?autostarttoken=${state.autoStartToken}&redirect=null") + context.startActivity(Intent(Intent.ACTION_VIEW, bankIdUri)) + viewModel.emit(SigningEvent.BankIdOpened) + }, + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } } is SigningUiState.Success -> { @@ -45,3 +94,97 @@ internal fun SigningDestination( } } } + +@Composable +private fun QrCodeSigningScreen( + liveQrCodeData: String?, + onOpenBankId: () -> Unit, +) { + HedvigScaffold(navigateUp = {}) { + Spacer(Modifier.weight(1f)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + HedvigText( + text = "Logga in med BankID", + style = HedvigTheme.typography.headlineMedium, + ) + Spacer(Modifier.height(8.dp)) + HedvigText( + text = "Skanna QR-koden med BankID-appen på en annan enhet", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) + Spacer(Modifier.height(24.dp)) + if (liveQrCodeData != null) { + QRCode( + data = liveQrCodeData, + modifier = Modifier.size(200.dp), + ) + } else { + HedvigFullScreenCenterAlignedProgress() + } + Spacer(Modifier.height(24.dp)) + HedvigButton( + text = "Öppna BankID", + onClick = onOpenBankId, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.weight(1f)) + } +} + +@Composable +private fun QRCode(data: String, modifier: Modifier = Modifier) { + var intSize: IntSize? by remember { mutableStateOf(null) } + val painter by produceState(ColorPainter(Color.Transparent), intSize, data) { + val size = intSize ?: return@produceState + val bitmapPainter: BitmapPainter = withContext(Dispatchers.Default) { + val bitMatrix: BitMatrix = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + size.width, + size.height, + ) + val bitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.RGB_565) + for (x in 0 until size.width) { + for (y in 0 until size.height) { + val color = if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE + bitmap.setPixel(x, y, color) + } + } + BitmapPainter(bitmap.asImageBitmap()) + } + value = bitmapPainter + } + Image( + painter, + contentDescription = "BankID QR code", + modifier.onSizeChanged { intSize = it }, + ) +} + +@SuppressLint("QueryPermissionsNeeded") +private fun canBankIdAppHandleUri(context: Context): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + BANK_ID_APP_PACKAGE_NAME, + PackageManager.PackageInfoFlags.of(0), + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(BANK_ID_APP_PACKAGE_NAME, 0) + } + true + } catch (e: PackageManager.NameNotFoundException) { + logcat(LogPriority.INFO) { "BankID app not installed, will show QR code" } + false + } +} + +private const val BANK_ID_APP_PACKAGE_NAME = "com.bankid.bus" diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt index de92711b7e..9a29bf3408 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -21,12 +21,10 @@ internal class SigningViewModel( initialState = SigningUiState.Polling( autoStartToken = signingParameters.autoStartToken, startDate = signingParameters.startDate, + liveQrCodeData = null, bankIdOpened = false, ), - presenter = SigningPresenter( - signingParameters, - pollSigningStatusUseCase, - ), + presenter = SigningPresenter(signingParameters, pollSigningStatusUseCase), ) internal class SigningPresenter( @@ -40,13 +38,8 @@ internal class SigningPresenter( CollectEvents { event -> when (event) { - SigningEvent.BankIdOpened -> { - bankIdOpened = true - } - - SigningEvent.ClearNavigation -> { - // Reset state if needed - } + SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.ClearNavigation -> {} } } @@ -58,8 +51,8 @@ internal class SigningPresenter( currentState = SigningUiState.Failed return@LaunchedEffect }, - ifRight = { status -> - when (status) { + ifRight = { pollResult -> + when (pollResult.status) { SigningStatus.SIGNED -> { currentState = SigningUiState.Success(startDate = signingParameters.startDate) return@LaunchedEffect @@ -70,7 +63,14 @@ internal class SigningPresenter( return@LaunchedEffect } - SigningStatus.PENDING -> { /* continue polling */ } + SigningStatus.PENDING -> { + currentState = SigningUiState.Polling( + autoStartToken = signingParameters.autoStartToken, + startDate = signingParameters.startDate, + liveQrCodeData = pollResult.liveQrCodeData, + bankIdOpened = bankIdOpened, + ) + } } }, ) @@ -88,6 +88,7 @@ internal sealed interface SigningUiState { data class Polling( val autoStartToken: String, val startDate: String?, + val liveQrCodeData: String?, val bankIdOpened: Boolean, ) : SigningUiState From a6fcb3027f441972c78b7dd71e7802a7adb6d23d Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 16:07:47 +0200 Subject: [PATCH 19/22] fix: resolve signing navigation, group tiers by deductible, improve form and summary UI - Fix signing->success navigation getting stuck by using Unit as LaunchedEffect key instead of state (fires once when entering Success/Failed branch) - Group tier offers by tierDisplayName with deductible radio selector per tier, defaulting to lowest deductible - Add horizontal padding (16dp) to form content and subtitle text - Show strikethrough gross price on summary when discounted - Show deductible display in summary card - Fix HedvigButton missing required enabled parameter in QR code screen Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data/PollSigningStatusUseCase.kt | 1 + .../ui/form/ApartmentFormDestination.kt | 32 ++-- .../ui/offer/SelectTierDestination.kt | 161 +++++++++++------- .../apartment/ui/offer/SelectTierViewModel.kt | 93 ++++++++-- .../apartment/ui/sign/SigningDestination.kt | 12 +- .../apartment/ui/sign/SigningViewModel.kt | 5 +- .../ui/summary/PurchaseSummaryDestination.kt | 49 +++++- 7 files changed, 251 insertions(+), 102 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt index b38438ed31..eab49f3224 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -31,6 +31,7 @@ internal class PollSigningStatusUseCaseImpl( val signing = result.shopSessionSigning val status = when (signing.status) { ShopSessionSigningStatus.SIGNED -> SigningStatus.SIGNED + ShopSessionSigningStatus.FAILED -> SigningStatus.FAILED ShopSessionSigningStatus.PENDING, diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt index 135d03694c..c91931782e 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/form/ApartmentFormDestination.kt @@ -3,11 +3,9 @@ package com.hedvig.android.feature.purchase.apartment.ui.form import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,6 +25,7 @@ import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProg 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 @@ -54,11 +53,15 @@ internal fun ApartmentFormDestination( topAppBarText = "Hemförsäkring", ) { when { - uiState.isLoadingSession -> HedvigFullScreenCenterAlignedProgress() + uiState.isLoadingSession -> { + HedvigFullScreenCenterAlignedProgress() + } - uiState.loadSessionError -> HedvigErrorSection( - onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, - ) + uiState.loadSessionError -> { + HedvigErrorSection( + onButtonClick = { viewModel.emit(ApartmentFormEvent.Retry) }, + ) + } else -> { var street by remember { mutableStateOf("") } @@ -116,8 +119,16 @@ private fun ApartmentFormContent( onRetry: () -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), ) { + Spacer(Modifier.height(16.dp)) + HedvigText( + text = "Fyll i dina uppgifter s\u00e5 ber\u00e4knar vi ditt pris", + style = HedvigTheme.typography.bodyMedium, + color = HedvigTheme.colorScheme.textSecondary, + ) Spacer(Modifier.height(16.dp)) Column( modifier = Modifier.fillMaxWidth(), @@ -162,7 +173,7 @@ private fun ApartmentFormContent( else -> "Du + $numberCoInsured" }, stepperSize = Medium, - stepperStyle = Labeled("Antal medförsäkrade"), + stepperStyle = Labeled("Antal medf\u00f6rs\u00e4krade"), onMinusClick = { onNumberCoInsuredChanged(numberCoInsured - 1) }, onPlusClick = { onNumberCoInsuredChanged(numberCoInsured + 1) }, isPlusEnabled = !isSubmitting && numberCoInsured < 5, @@ -171,14 +182,13 @@ private fun ApartmentFormContent( } Spacer(Modifier.height(16.dp)) HedvigButton( - text = "Beräkna pris", + text = "Ber\u00e4kna pris", onClick = onSubmit, enabled = !isSubmitting, isLoading = isSubmitting, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt index 5273afad4f..894904d634 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierDestination.kt @@ -26,10 +26,13 @@ import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioGroupSize +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters -import com.hedvig.android.feature.purchase.apartment.navigation.TierOfferData import java.text.NumberFormat import java.util.Currency import java.util.Locale @@ -50,7 +53,10 @@ internal fun SelectTierDestination( SelectTierContent( uiState = uiState, navigateUp = navigateUp, - onSelectOffer = { viewModel.emit(SelectTierEvent.SelectOffer(it)) }, + onSelectTier = { viewModel.emit(SelectTierEvent.SelectTier(it)) }, + onSelectDeductible = { tierName, offerId -> + viewModel.emit(SelectTierEvent.SelectDeductible(tierName, offerId)) + }, onContinue = { viewModel.emit(SelectTierEvent.Continue) }, ) } @@ -59,7 +65,8 @@ internal fun SelectTierDestination( private fun SelectTierContent( uiState: SelectTierUiState, navigateUp: () -> Unit = {}, - onSelectOffer: (String) -> Unit = {}, + onSelectTier: (String) -> Unit = {}, + onSelectDeductible: (tierName: String, offerId: String) -> Unit = { _, _ -> }, onContinue: () -> Unit = {}, ) { HedvigScaffold( @@ -79,14 +86,20 @@ private fun SelectTierContent( modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(Modifier.height(24.dp)) - for ((index, offer) in uiState.offers.withIndex()) { - TierCard( - offer = offer, - isSelected = offer.offerId == uiState.selectedOfferId, - onSelect = { onSelectOffer(offer.offerId) }, + for ((index, tierGroup) in uiState.tierGroups.withIndex()) { + val isSelected = tierGroup.tierDisplayName == uiState.selectedTierName + val selectedDeductibleId = uiState.selectedDeductibleByTier[tierGroup.tierDisplayName] + val selectedDeductible = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } + ?: tierGroup.deductibleOptions.firstOrNull() + TierGroupCard( + tierGroup = tierGroup, + isSelected = isSelected, + selectedDeductibleId = selectedDeductible?.offerId ?: "", + onSelectTier = { onSelectTier(tierGroup.tierDisplayName) }, + onSelectDeductible = { offerId -> onSelectDeductible(tierGroup.tierDisplayName, offerId) }, modifier = Modifier.padding(horizontal = 16.dp), ) - if (index < uiState.offers.lastIndex) { + if (index < uiState.tierGroups.lastIndex) { Spacer(Modifier.height(12.dp)) } } @@ -94,7 +107,7 @@ private fun SelectTierContent( HedvigButton( text = "Forts\u00e4tt", onClick = dropUnlessResumed { onContinue() }, - enabled = uiState.offers.any { it.offerId == uiState.selectedOfferId }, + enabled = uiState.selectedTierName.isNotEmpty(), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -104,14 +117,17 @@ private fun SelectTierContent( } @Composable -private fun TierCard( - offer: TierOfferData, +private fun TierGroupCard( + tierGroup: TierGroup, isSelected: Boolean, - onSelect: () -> Unit, + selectedDeductibleId: String, + onSelectTier: () -> Unit, + onSelectDeductible: (String) -> Unit, modifier: Modifier = Modifier, ) { + val selectedOption = tierGroup.deductibleOptions.firstOrNull { it.offerId == selectedDeductibleId } HedvigCard( - onClick = onSelect, + onClick = onSelectTier, borderColor = if (isSelected) { HedvigTheme.colorScheme.signalGreenElement } else { @@ -126,13 +142,15 @@ private fun TierCard( verticalAlignment = Alignment.CenterVertically, ) { HedvigText( - text = offer.tierDisplayName, - style = HedvigTheme.typography.bodyLarge, - ) - HedvigText( - text = formatPrice(offer.netAmount, offer.netCurrencyCode), + text = tierGroup.tierDisplayName, style = HedvigTheme.typography.bodyLarge, ) + if (selectedOption != null) { + HedvigText( + text = formatPrice(selectedOption.netAmount, selectedOption.netCurrencyCode), + style = HedvigTheme.typography.bodyLarge, + ) + } } AnimatedVisibility( visible = isSelected, @@ -140,9 +158,9 @@ private fun TierCard( exit = shrinkVertically(), ) { Column { - if (offer.usps.isNotEmpty()) { + if (tierGroup.usps.isNotEmpty()) { Spacer(Modifier.height(12.dp)) - for (usp in offer.usps) { + for (usp in tierGroup.usps) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp), @@ -162,6 +180,26 @@ private fun TierCard( } } } + if (tierGroup.deductibleOptions.size > 1) { + Spacer(Modifier.height(12.dp)) + HedvigText( + text = "Sj\u00e4lvrisk", + style = HedvigTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(4.dp)) + RadioGroup( + options = tierGroup.deductibleOptions.map { option -> + RadioOption( + id = RadioOptionId(option.offerId), + text = option.deductibleDisplayName, + label = formatPrice(option.netAmount, option.netCurrencyCode), + ) + }, + selectedOption = RadioOptionId(selectedDeductibleId), + onRadioOptionSelected = { onSelectDeductible(it.id) }, + size = RadioGroupSize.Small, + ) + } } } AnimatedVisibility( @@ -172,7 +210,7 @@ private fun TierCard( Column { Spacer(Modifier.height(8.dp)) HedvigText( - text = "V\u00e4lj ${offer.tierDisplayName}", + text = "V\u00e4lj ${tierGroup.tierDisplayName}", style = HedvigTheme.typography.bodyMedium, color = HedvigTheme.colorScheme.textSecondary, ) @@ -190,45 +228,43 @@ private fun formatPrice(amount: Double, currencyCode: String): String { return "${format.format(amount)}/m\u00e5n" } -private val previewOffers = listOf( - TierOfferData( - offerId = "1", +private val previewTierGroups = listOf( + TierGroup( tierDisplayName = "Hem Max", - tierDescription = "Vårt mest omfattande skydd", - grossAmount = 189.0, - grossCurrencyCode = "SEK", - netAmount = 189.0, - netCurrencyCode = "SEK", - usps = listOf("Försäkringsbelopp 1 000 000 kr", "Drulle upp till 50 000 kr ingår", "ID-skydd och flyttskydd"), - exposureDisplayName = "Storgatan 1", - deductibleDisplayName = "1 500 kr", - hasDiscount = false, + tierDescription = "V\u00e5rt mest omfattande skydd", + usps = listOf( + "F\u00f6rs\u00e4kringsbelopp 1 000 000 kr", + "Drulle upp till 50 000 kr ing\u00e5r", + "ID-skydd och flyttskydd", + ), + deductibleOptions = listOf( + DeductibleOption("1a", "1 500 kr", 189.0, "SEK", 189.0, "SEK", false), + DeductibleOption("1b", "3 000 kr", 169.0, "SEK", 169.0, "SEK", false), + DeductibleOption("1c", "5 000 kr", 149.0, "SEK", 149.0, "SEK", false), + ), ), - TierOfferData( - offerId = "2", + TierGroup( tierDisplayName = "Hem Standard", - tierDescription = "Vår mest populära försäkring", - grossAmount = 139.0, - grossCurrencyCode = "SEK", - netAmount = 118.0, - netCurrencyCode = "SEK", - usps = listOf("Försäkringsbelopp 1 000 000 kr", "Drulle upp till 50 000 kr ingår"), - exposureDisplayName = "Storgatan 1", - deductibleDisplayName = "1 500 kr", - hasDiscount = true, + tierDescription = "V\u00e5r mest popul\u00e4ra f\u00f6rs\u00e4kring", + usps = listOf( + "F\u00f6rs\u00e4kringsbelopp 1 000 000 kr", + "Drulle upp till 50 000 kr ing\u00e5r", + ), + deductibleOptions = listOf( + DeductibleOption("2a", "1 500 kr", 139.0, "SEK", 118.0, "SEK", true), + DeductibleOption("2b", "3 000 kr", 119.0, "SEK", 99.0, "SEK", true), + DeductibleOption("2c", "5 000 kr", 99.0, "SEK", 85.0, "SEK", true), + ), ), - TierOfferData( - offerId = "3", + TierGroup( tierDisplayName = "Hem Bas", - tierDescription = "Innehåller vårt grundskydd", - grossAmount = 99.0, - grossCurrencyCode = "SEK", - netAmount = 99.0, - netCurrencyCode = "SEK", + tierDescription = "Inneh\u00e5ller v\u00e5rt grundskydd", usps = listOf("Grundskydd"), - exposureDisplayName = "Storgatan 1", - deductibleDisplayName = "1 500 kr", - hasDiscount = false, + deductibleOptions = listOf( + DeductibleOption("3a", "1 500 kr", 99.0, "SEK", 99.0, "SEK", false), + DeductibleOption("3b", "3 000 kr", 79.0, "SEK", 79.0, "SEK", false), + DeductibleOption("3c", "5 000 kr", 65.0, "SEK", 65.0, "SEK", false), + ), ), ) @@ -238,14 +274,17 @@ private fun PreviewSelectTierStandard() { HedvigTheme { SelectTierContent( uiState = SelectTierUiState( - offers = previewOffers, - selectedOfferId = "2", + tierGroups = previewTierGroups, + selectedTierName = "Hem Standard", + selectedDeductibleByTier = mapOf( + "Hem Max" to "1c", + "Hem Standard" to "2a", + "Hem Bas" to "3c", + ), shopSessionId = "session", - productDisplayName = "Hemförsäkring Hyresrätt", - summaryToNavigate = null + productDisplayName = "Hemf\u00f6rs\u00e4kring Hyresr\u00e4tt", + summaryToNavigate = null, ), - onSelectOffer = {}, - onContinue = {}, ) } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt index cbcac57417..bebd2f7edf 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/offer/SelectTierViewModel.kt @@ -15,33 +15,71 @@ import com.hedvig.android.molecule.public.MoleculeViewModel internal class SelectTierViewModel( params: SelectTierParameters, ) : MoleculeViewModel( - SelectTierUiState( - offers = params.offers, - selectedOfferId = params.offers.firstOrNull { it.tierDisplayName == "Standard" }?.offerId - ?: params.offers.firstOrNull()?.offerId - ?: "", - shopSessionId = params.shopSessionId, - productDisplayName = params.productDisplayName, - summaryToNavigate = null, - ), + buildInitialState(params), SelectTierPresenter(params), ) +private fun buildInitialState(params: SelectTierParameters): SelectTierUiState { + val tierGroups = groupOffersByTier(params.offers) + val defaultTierName = tierGroups.firstOrNull { "Standard" in it.tierDisplayName }?.tierDisplayName + ?: tierGroups.firstOrNull()?.tierDisplayName + ?: "" + val defaultDeductibleByTier = tierGroups.associate { group -> + group.tierDisplayName to (group.deductibleOptions.minByOrNull { it.netAmount }?.offerId ?: "") + } + return SelectTierUiState( + tierGroups = tierGroups, + selectedTierName = defaultTierName, + selectedDeductibleByTier = defaultDeductibleByTier, + shopSessionId = params.shopSessionId, + productDisplayName = params.productDisplayName, + summaryToNavigate = null, + ) +} + +private fun groupOffersByTier(offers: List): List { + return offers.groupBy { it.tierDisplayName }.map { (tierName, tierOffers) -> + val first = tierOffers.first() + TierGroup( + tierDisplayName = tierName, + tierDescription = first.tierDescription, + usps = first.usps, + deductibleOptions = tierOffers.map { offer -> + DeductibleOption( + offerId = offer.offerId, + deductibleDisplayName = offer.deductibleDisplayName ?: "", + netAmount = offer.netAmount, + netCurrencyCode = offer.netCurrencyCode, + grossAmount = offer.grossAmount, + grossCurrencyCode = offer.grossCurrencyCode, + hasDiscount = offer.hasDiscount, + ) + }.sortedBy { it.netAmount }, + ) + } +} + internal class SelectTierPresenter( private val params: SelectTierParameters, ) : MoleculePresenter { @Composable override fun MoleculePresenterScope.present(lastState: SelectTierUiState): SelectTierUiState { - var selectedOfferId by remember { mutableStateOf(lastState.selectedOfferId) } + var selectedTierName by remember { mutableStateOf(lastState.selectedTierName) } + var selectedDeductibleByTier by remember { mutableStateOf(lastState.selectedDeductibleByTier) } var summaryToNavigate: SummaryParameters? by remember { mutableStateOf(lastState.summaryToNavigate) } CollectEvents { event -> when (event) { - is SelectTierEvent.SelectOffer -> { - selectedOfferId = event.offerId + is SelectTierEvent.SelectTier -> { + selectedTierName = event.tierName + } + + is SelectTierEvent.SelectDeductible -> { + selectedDeductibleByTier = selectedDeductibleByTier + (event.tierName to event.offerId) } SelectTierEvent.Continue -> { + val selectedOfferId = selectedDeductibleByTier[selectedTierName] ?: return@CollectEvents val selectedOffer = params.offers.first { it.offerId == selectedOfferId } summaryToNavigate = SummaryParameters( shopSessionId = params.shopSessionId, @@ -57,8 +95,9 @@ internal class SelectTierPresenter( } return SelectTierUiState( - offers = params.offers, - selectedOfferId = selectedOfferId, + tierGroups = lastState.tierGroups, + selectedTierName = selectedTierName, + selectedDeductibleByTier = selectedDeductibleByTier, shopSessionId = params.shopSessionId, productDisplayName = params.productDisplayName, summaryToNavigate = summaryToNavigate, @@ -66,16 +105,36 @@ internal class SelectTierPresenter( } } +internal data class TierGroup( + val tierDisplayName: String, + val tierDescription: String, + val usps: List, + val deductibleOptions: List, +) + +internal data class DeductibleOption( + val offerId: String, + val deductibleDisplayName: String, + val netAmount: Double, + val netCurrencyCode: String, + val grossAmount: Double, + val grossCurrencyCode: String, + val hasDiscount: Boolean, +) + internal data class SelectTierUiState( - val offers: List, - val selectedOfferId: String, + val tierGroups: List, + val selectedTierName: String, + val selectedDeductibleByTier: Map, val shopSessionId: String, val productDisplayName: String, val summaryToNavigate: SummaryParameters?, ) internal sealed interface SelectTierEvent { - data class SelectOffer(val offerId: String) : SelectTierEvent + data class SelectTier(val tierName: String) : SelectTierEvent + + data class SelectDeductible(val tierName: String, val offerId: String) : SelectTierEvent data object Continue : SelectTierEvent diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt index 2557c4587f..e112d7f7cf 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -80,14 +80,14 @@ internal fun SigningDestination( } is SigningUiState.Success -> { - LaunchedEffect(state) { + LaunchedEffect(Unit) { navigateToSuccess(state.startDate) } HedvigFullScreenCenterAlignedProgress() } is SigningUiState.Failed -> { - LaunchedEffect(state) { + LaunchedEffect(Unit) { navigateToFailure() } HedvigFullScreenCenterAlignedProgress() @@ -96,10 +96,7 @@ internal fun SigningDestination( } @Composable -private fun QrCodeSigningScreen( - liveQrCodeData: String?, - onOpenBankId: () -> Unit, -) { +private fun QrCodeSigningScreen(liveQrCodeData: String?, onOpenBankId: () -> Unit) { HedvigScaffold(navigateUp = {}) { Spacer(Modifier.weight(1f)) Column( @@ -129,8 +126,9 @@ private fun QrCodeSigningScreen( } Spacer(Modifier.height(24.dp)) HedvigButton( - text = "Öppna BankID", + text = "\u00d6ppna BankID", onClick = onOpenBankId, + enabled = true, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt index 9a29bf3408..33523960bc 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -38,7 +38,10 @@ internal class SigningPresenter( CollectEvents { event -> when (event) { - SigningEvent.BankIdOpened -> bankIdOpened = true + SigningEvent.BankIdOpened -> { + bankIdOpened = true + } + SigningEvent.ClearNavigation -> {} } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt index 4ae1728c4d..8cc0899aac 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/summary/PurchaseSummaryDestination.kt @@ -1,25 +1,28 @@ package com.hedvig.android.feature.purchase.apartment.ui.summary import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row 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.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle.Primary import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken -import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.purchase.apartment.navigation.SigningParameters import com.hedvig.android.feature.purchase.apartment.navigation.SummaryParameters @@ -80,6 +83,26 @@ private fun PurchaseSummaryScreen( text = offer.exposureDisplayName, style = HedvigTheme.typography.bodySmall, ) + if (offer.deductibleDisplayName != null) { + Spacer(Modifier.height(4.dp)) + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { + HedvigText( + text = "Sj\u00e4lvrisk", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + }, + spaceBetween = 8.dp, + endSlot = { + HedvigText( + text = offer.deductibleDisplayName, + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + ) + }, + ) + } Spacer(Modifier.height(16.dp)) HorizontalItemsWithMaximumSpaceTaken( startSlot = { @@ -90,10 +113,26 @@ private fun PurchaseSummaryScreen( }, spaceBetween = 8.dp, endSlot = { - HedvigText( - text = "${offer.netAmount} ${offer.netCurrencyCode}/mån", - style = HedvigTheme.typography.bodySmall, - ) + if (offer.hasDiscount && offer.grossAmount != offer.netAmount) { + Row { + HedvigText( + text = "${offer.grossAmount.toInt()} ${offer.grossCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + color = HedvigTheme.colorScheme.textSecondary, + textDecoration = TextDecoration.LineThrough, + ) + Spacer(Modifier.width(4.dp)) + HedvigText( + text = "${offer.netAmount.toInt()} ${offer.netCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + ) + } + } else { + HedvigText( + text = "${offer.netAmount.toInt()} ${offer.netCurrencyCode}/m\u00e5n", + style = HedvigTheme.typography.bodySmall, + ) + } }, ) } From a91b17e4b9a6bc596cc13b9c74be5d27c16cbb13 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 16:18:29 +0200 Subject: [PATCH 20/22] fix: fix BankID signing - poll immediately, skip cache, robust navigation Three fixes for the signing flow: 1. Poll immediately on first iteration instead of waiting 2s 2. Use FetchPolicy.NetworkOnly to skip Apollo cache (was returning stale QR code data and stale signing status) 3. Use a hasNavigated flag for success/failure navigation instead of LaunchedEffect(Unit) which could fail on recomposition Co-Authored-By: Claude Opus 4.6 (1M context) --- .../data/PollSigningStatusUseCase.kt | 4 +++ .../apartment/ui/sign/SigningDestination.kt | 34 +++++++++++-------- .../apartment/ui/sign/SigningViewModel.kt | 2 +- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt index eab49f3224..6354ccd274 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -3,6 +3,9 @@ package com.hedvig.android.feature.purchase.apartment.data import arrow.core.Either import arrow.core.raise.either import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.ApolloResponse +import com.apollographql.apollo.cache.normalized.fetchPolicy +import com.apollographql.apollo.cache.normalized.FetchPolicy import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.logger.LogPriority @@ -21,6 +24,7 @@ internal class PollSigningStatusUseCaseImpl( return either { apolloClient .query(ApartmentShopSessionSigningQuery(signingId = signingId)) + .fetchPolicy(FetchPolicy.NetworkOnly) .safeExecute() .fold( ifLeft = { diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt index e112d7f7cf..490eff39a7 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningDestination.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,7 +33,6 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.zxing.BarcodeFormat import com.google.zxing.common.BitMatrix import com.google.zxing.qrcode.QRCodeWriter @@ -55,6 +55,22 @@ internal fun SigningDestination( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val canOpenBankId = remember { canBankIdAppHandleUri(context) } + var hasNavigated by remember { mutableStateOf(false) } + + LaunchedEffect(uiState) { + if (hasNavigated) return@LaunchedEffect + when (val state = uiState) { + is SigningUiState.Success -> { + hasNavigated = true + navigateToSuccess(state.startDate) + } + is SigningUiState.Failed -> { + hasNavigated = true + navigateToFailure() + } + is SigningUiState.Polling -> {} + } + } when (val state = uiState) { is SigningUiState.Polling -> { @@ -79,19 +95,9 @@ internal fun SigningDestination( } } - is SigningUiState.Success -> { - LaunchedEffect(Unit) { - navigateToSuccess(state.startDate) - } - HedvigFullScreenCenterAlignedProgress() - } - - is SigningUiState.Failed -> { - LaunchedEffect(Unit) { - navigateToFailure() - } - HedvigFullScreenCenterAlignedProgress() - } + is SigningUiState.Success, + is SigningUiState.Failed, + -> HedvigFullScreenCenterAlignedProgress() } } diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt index 33523960bc..49e98d1094 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/ui/sign/SigningViewModel.kt @@ -48,7 +48,6 @@ internal class SigningPresenter( LaunchedEffect(Unit) { while (true) { - delay(2_000) pollSigningStatusUseCase.invoke(signingParameters.signingId).fold( ifLeft = { currentState = SigningUiState.Failed @@ -77,6 +76,7 @@ internal class SigningPresenter( } }, ) + delay(2_000) } } From 34c5e60de2683f3a13b06f6692b34bcf683edc56 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 16:20:55 +0200 Subject: [PATCH 21/22] fix: add apollo-normalizedCache dep and remove unused import Co-Authored-By: Claude Opus 4.6 (1M context) --- app/feature/feature-purchase-apartment/build.gradle.kts | 1 + .../purchase/apartment/data/PollSigningStatusUseCase.kt | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/feature/feature-purchase-apartment/build.gradle.kts b/app/feature/feature-purchase-apartment/build.gradle.kts index ed61c94ec1..58daa4a1f0 100644 --- a/app/feature/feature-purchase-apartment/build.gradle.kts +++ b/app/feature/feature-purchase-apartment/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api(libs.androidx.navigation.common) implementation(libs.androidx.navigation.compose) + implementation(libs.apollo.normalizedCache) implementation(libs.arrow.core) implementation(libs.arrow.fx) implementation(libs.jetbrains.lifecycle.runtime.compose) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt index 6354ccd274..0cf4719af1 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/data/PollSigningStatusUseCase.kt @@ -3,9 +3,8 @@ package com.hedvig.android.feature.purchase.apartment.data import arrow.core.Either import arrow.core.raise.either import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.api.ApolloResponse -import com.apollographql.apollo.cache.normalized.fetchPolicy import com.apollographql.apollo.cache.normalized.FetchPolicy +import com.apollographql.apollo.cache.normalized.fetchPolicy import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.logger.LogPriority From 7186aa9966ecc00b3eaa2c454160f72f7f3db2f4 Mon Sep 17 00:00:00 2001 From: Hugo Linder Date: Tue, 31 Mar 2026 16:40:45 +0200 Subject: [PATCH 22/22] fix: success screen returns to insurance tab instead of closing app The purchase graph is already popped when navigating to Success, so typedPopBackStack for the graph fails and falls through to finishApp(). Use plain popBackStack() instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt index 7c7ca977ef..11f8293c71 100644 --- a/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt +++ b/app/feature/feature-purchase-apartment/src/main/kotlin/com/hedvig/android/feature/purchase/apartment/navigation/ApartmentPurchaseNavGraph.kt @@ -135,7 +135,7 @@ fun NavGraphBuilder.apartmentPurchaseNavGraph( PurchaseSuccessDestination( startDate = route.startDate, close = dropUnlessResumed { - if (!navController.typedPopBackStack(inclusive = true)) finishApp() + if (!navController.popBackStack()) finishApp() }, ) }